diff --git a/CHANGELOG.md b/CHANGELOG.md index f6db523..4b78da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [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 diff --git a/Cargo.lock b/Cargo.lock index 41638a2..fe11b51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,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" @@ -267,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", @@ -291,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", ] @@ -1082,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" @@ -1232,9 +1320,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http 1.3.1", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1260,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" @@ -1624,10 +1727,10 @@ dependencies = [ [[package]] name = "masterror" -version = "0.14.1" +version = "0.15.0" dependencies = [ "actix-web", - "axum", + "axum 0.8.4", "config", "http 1.3.1", "js-sys", @@ -1648,6 +1751,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "tonic", "tracing", "tracing-subscriber", "trybuild", @@ -1671,6 +1775,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" @@ -2074,6 +2184,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" @@ -2333,7 +2452,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -3296,6 +3415,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" @@ -3348,6 +3478,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" @@ -3376,7 +3556,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] diff --git a/Cargo.toml b/Cargo.toml index 68dbcc2..2d41449 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.14.1" +version = "0.15.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -71,6 +71,7 @@ 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] @@ -119,6 +120,7 @@ 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" @@ -156,6 +158,7 @@ feature_order = [ "multipart", "teloxide", "telegram-webapp-sdk", + "tonic", "frontend", "turnkey", ] @@ -225,6 +228,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 45823d1..74a5e9e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ 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`, `Metadata` +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ProblemJson`, `ErrorResponse`, `Metadata` - Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[masterror(...)]`, `#[provide]` for domain mappings and structured telemetry @@ -38,20 +38,22 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.14.1", default-features = false } +masterror = { version = "0.15.0", default-features = false } # or with features: -# masterror = { version = "0.14.1", features = [ +# masterror = { version = "0.15.0", features = [ + # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", # "config", "tokio", "multipart", "teloxide", -# "telegram-webapp-sdk", "frontend", "turnkey" +# "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.* +*Since v0.15.0: RFC7807 `ProblemJson` responses for HTTP integrations and `tonic::Status` conversion.* --- @@ -61,7 +63,7 @@ masterror = { version = "0.14.1", default-features = false } - **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? }`. +- **Clean wire contract.** `ProblemJson { type?, title, status, detail?, code, grpc?, metadata? }` with `Retry-After` / `WWW-Authenticate` headers when present. - **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the @@ -77,15 +79,15 @@ masterror = { version = "0.14.1", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.14.1", default-features = false } +masterror = { version = "0.15.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.14.1", features = [ +# masterror = { version = "0.15.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", # "config", "tokio", "multipart", "teloxide", -# "telegram-webapp-sdk", "frontend", "turnkey" +# "telegram-webapp-sdk", "tonic", "frontend", "turnkey" # ] } ~~~ @@ -619,15 +621,18 @@ assert_eq!(display.to_string(), "404: Not Found"); Error response payload ~~~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""#); +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""#) +); -assert_eq!(resp.status, 401); +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); ~~~ @@ -686,6 +691,7 @@ assert_eq!(resp.status, 401); - `multipart` — Handle axum multipart extraction errors - `teloxide` — Convert teloxide_core::RequestError into domain errors - `telegram-webapp-sdk` — Surface Telegram WebApp validation failures +- `tonic` — Convert AppError into tonic::Status with redaction - `frontend` — Log to the browser console and convert to JsValue on WASM - `turnkey` — Ship Turnkey-specific error taxonomy and conversions @@ -714,13 +720,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.14.1", default-features = false } +masterror = { version = "0.15.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.14.1", features = [ +masterror = { version = "0.15.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -729,7 +735,7 @@ masterror = { version = "0.14.1", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.14.1", features = [ +masterror = { version = "0.15.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index e16cf76..1354483 100644 --- a/README.ru.md +++ b/README.ru.md @@ -19,7 +19,7 @@ ## Основные возможности -- Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata`. +- Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ProblemJson`, `ErrorResponse`, `Metadata`. - Деривы `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[masterror(...)]`, `#[provide]` для типизированного телеметрического контекста и прямых конверсий доменных ошибок. @@ -39,9 +39,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.13.1", default-features = false } +masterror = { version = "0.15.0", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.13.1", features = [ +# masterror = { version = "0.15.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -91,6 +91,7 @@ fn do_work(flag: bool) -> AppResult<()> { - `multipart` — обработка ошибок извлечения multipart в Axum. - `teloxide` — маппинг `teloxide_core::RequestError` в доменные категории. - `telegram-webapp-sdk` — обработка ошибок валидации данных Telegram WebApp. +- `tonic` — преобразование `AppError` в `tonic::Status` с учётом редактирования. - `frontend` — логирование в браузере и преобразование в `JsValue` для WASM. - `turnkey` — расширение таксономии для Turnkey SDK. diff --git a/README.template.md b/README.template.md index 1b399e7..886ef7b 100644 --- a/README.template.md +++ b/README.template.md @@ -19,7 +19,7 @@ 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`, `Metadata` +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ProblemJson`, `ErrorResponse`, `Metadata` - Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[masterror(...)]`, `#[provide]` for domain mappings and structured telemetry @@ -48,6 +48,7 @@ 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.* +*Since v0.15.0: RFC7807 `ProblemJson` responses for HTTP integrations and `tonic::Status` conversion.* --- @@ -57,7 +58,7 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } - **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? }`. +- **Clean wire contract.** `ProblemJson { type?, title, status, detail?, code, grpc?, metadata? }` with `Retry-After` / `WWW-Authenticate` headers when present. - **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the @@ -611,15 +612,18 @@ assert_eq!(display.to_string(), "404: Not Found"); Error response payload ~~~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""#); +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""#) +); -assert_eq!(resp.status, 401); +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); ~~~ diff --git a/src/convert.rs b/src/convert.rs index cd9258b..7b74603 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -122,6 +122,10 @@ 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; + /// 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 e91edc9..b58108a 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 @@ -72,7 +81,9 @@ use actix_web::{ }; #[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,18 +94,11 @@ 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 { self.emit_telemetry(); - 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) + let problem = ProblemJson::from_ref(self); + respond_with_problem_json(problem) } } @@ -106,7 +110,7 @@ mod actix_tests { http::header::{RETRY_AFTER, WWW_AUTHENTICATE} }; - use crate::{AppCode, AppError, AppErrorKind, AppResult, ErrorResponse}; + use crate::{AppCode, AppError, AppErrorKind, AppResult, ProblemJson}; #[test] fn maps_status_consistently() { @@ -134,10 +138,10 @@ mod actix_tests { ); let bytes = to_bytes(resp.into_body()).await?; - let body: ErrorResponse = serde_json::from_slice(&bytes)?; + let body: ProblemJson = serde_json::from_slice(&bytes)?; assert_eq!(body.status, 401); assert!(matches!(body.code, AppCode::Unauthorized)); - assert_eq!(body.message, "no token"); + assert_eq!(body.detail.as_deref(), Some("no token")); Ok(()) } } diff --git a/src/convert/axum.rs b/src/convert/axum.rs index 6abcf1b..14abf21 100644 --- a/src/convert/axum.rs +++ b/src/convert/axum.rs @@ -6,17 +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. +//! 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 //! @@ -46,9 +45,7 @@ use axum::{ response::{IntoResponse, Response} }; -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`]. @@ -65,18 +62,8 @@ impl AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { let err = self; - err.emit_telemetry(); - let status = err.http_status(); - - #[cfg(feature = "serde_json")] - { - // Build the stable wire contract (includes `code`). - let body: ErrorResponse = err.into(); - return body.into_response(); - } - - #[allow(unreachable_code)] - (status, ()).into_response() + let problem = ProblemJson::from_app_error(err); + problem.into_response() } } @@ -101,51 +88,71 @@ 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"); - let resp = app_err.into_response(); + 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 mut 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 = + let body: crate::response::ProblemJson = 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()); + assert_eq!(body.detail.as_deref(), Some("missing token")); + assert!(body.metadata.is_none()); + assert!(body.grpc.is_some()); } - // --- IntoResponse without JSON body (serde_json disabled) ---------------- - - #[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 resp = app_err.into_response(); + let app_err = AppError::internal("secret").redactable(); + let mut 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: crate::response::ProblemJson = + serde_json::from_slice(&bytes).expect("json body"); + + assert!(body.detail.is_none()); + assert!(body.metadata.is_none()); } } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs new file mode 100644 index 0000000..4126503 --- /dev/null +++ b/src/convert/tonic.rs @@ -0,0 +1,204 @@ +//! 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, AppErrorKind}; +//! +//! let status = tonic::Status::try_from(AppError::not_found("missing"))?; +//! assert_eq!(status.code(), tonic::Code::NotFound); +//! ``` + +#![cfg(feature = "tonic")] +#![cfg_attr(docsrs, doc(cfg(feature = "tonic")))] + +use std::{borrow::Cow, convert::Infallible}; + +use tonic::{ + Code, Status, + metadata::{MetadataMap, MetadataValue} +}; + +#[cfg(test)] +use crate::CODE_MAPPINGS; +use crate::{ + AppErrorKind, Error, FieldValue, MessageEditPolicy, Metadata, RetryAdvice, mapping_for_code +}; + +impl TryFrom for Status { + type Error = Infallible; + + fn try_from(error: Error) -> Result { + Ok(status_from_error(error)) + } +} + +fn status_from_error(error: Error) -> Status { + error.emit_telemetry(); + let Error { + code, + kind, + message, + metadata, + edit_policy, + retry, + www_authenticate, + .. + } = error; + + let mapping = mapping_for_code(code); + let grpc_code = Code::from_i32(mapping.grpc().value); + let detail = sanitize_detail(message, kind, edit_policy); + let mut meta = MetadataMap::new(); + + insert_ascii(&mut meta, "app-code", 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) = retry { + insert_retry(&mut meta, advice); + } + if let Some(challenge) = www_authenticate { + if is_ascii_metadata_value(&challenge) { + insert_ascii(&mut meta, "www-authenticate", challenge); + } + } + + if !matches!(edit_policy, MessageEditPolicy::Redact) { + attach_metadata(&mut meta, metadata); + } + + Status::with_metadata(grpc_code, detail, meta) +} + +fn sanitize_detail( + message: Option>, + kind: AppErrorKind, + policy: MessageEditPolicy +) -> String { + if matches!(policy, MessageEditPolicy::Redact) { + return kind.to_string(); + } + + message.map_or_else(|| kind.to_string(), Cow::into_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 field in metadata { + let (name, value) = field.into_parts(); + 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 owned = value.into_owned(); + is_ascii_metadata_value(&owned).then_some(owned) + } + FieldValue::I64(value) => Some(value.to_string()), + FieldValue::U64(value) => Some(value.to_string()), + FieldValue::Bool(value) => Some(if value { "true" } else { "false" }.to_string()), + FieldValue::Uuid(value) => Some(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::try_from(err).expect("status"); + 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::try_from(err).expect("status"); + 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::try_from(err).expect("status"); + 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/lib.rs b/src/lib.rs index b5653b7..f10b00f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,8 @@ //! 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 @@ -33,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 @@ -50,8 +53,8 @@ //! 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 @@ -350,5 +353,11 @@ pub use kind::AppErrorKind; /// assert!(matches!(code, AppCode::BadRequest)); /// ``` pub use masterror_derive::{Error, Masterror}; -pub use response::{ErrorResponse, RetryAdvice}; +pub use response::{ + ErrorResponse, ProblemJson, RetryAdvice, + problem_json::{ + CODE_MAPPINGS, CodeMapping, GrpcCode, ProblemMetadata, ProblemMetadataValue, + mapping_for_code + } +}; pub use result_ext::ResultExt; diff --git a/src/response.rs b/src/response.rs index 9f5c1f3..760ea53 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 //! @@ -64,14 +61,17 @@ mod details; 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 70b2bf8..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,12 +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 { - let err = self; - err.emit_telemetry(); - let wire: ErrorResponse = err.into(); - wire.into_response() + ProblemJson::from_error_response(self).into_response() } } diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs new file mode 100644 index 0000000..d7adee6 --- /dev/null +++ b/src/response/problem_json.rs @@ -0,0 +1,756 @@ +use std::{borrow::Cow, collections::BTreeMap}; + +use http::StatusCode; +use serde::Serialize; + +use super::core::ErrorResponse; +use crate::{AppCode, AppError, AppErrorKind, 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<&'static str>, + /// 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(error: AppError) -> Self { + let err = error; + err.emit_telemetry(); + let AppError { + code, + kind, + message, + metadata, + edit_policy, + retry, + www_authenticate, + .. + } = err; + + 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(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(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(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 + } + } +} + +/// 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<&'static str, 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) = 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) = field.into_parts(); + public.insert(name, ProblemMetadataValue::from(value)); + } + + 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) in metadata.iter() { + public.insert(name, ProblemMetadataValue::from(value)); + } + + if public.is_empty() { + None + } else { + Some(ProblemMetadata(public)) + } +} + +/// 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::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 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 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/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index d416399..be76742 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,4 +8,5 @@ error: multiple unused formatting arguments | argument never used | argument never used | + = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index c3fb86b..113a10d 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index b331baa..9ada290 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index b2658a1..fd151cc 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 83d517f..5a25e12 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | #[error("missing")] - | ^ +8 | / #[error("missing")] +9 | | Missing + | |___________^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index f929951..bdadf45 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 34abc91..037fac8 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index d579838..1822edf 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default