From f8ba879bf162593e832f4a99207db843f0d71b6f Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:13:27 +0700 Subject: [PATCH] Add AppCode parser and fix lint issues --- CHANGELOG.md | 14 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 +++--- src/code.rs | 2 +- src/code/app_code.rs | 98 ++++++++++++++++++++++++++++++++++++++++- src/convert/axum.rs | 7 ++- src/convert/sqlx.rs | 3 +- src/convert/tonic.rs | 2 +- src/lib.rs | 2 +- src/response/details.rs | 1 + 11 files changed, 127 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5377d..cf24aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.4] - 2025-10-04 + +### Added +- Implemented `FromStr` support for `AppCode` together with a lightweight + `ParseAppCodeError` so RFC7807 responses and documentation examples can parse + machine codes without bespoke helpers. + +### Fixed +- Removed the redundant `#![cfg(feature = "axum")]` attribute and tightened + Axum, SQLx and Tonic integration tests to satisfy `-D warnings` builds. +- Allowed attaching JSON details via `ErrorResponse::with_details` without + tripping Clippy's `result_large_err` lint by documenting the intentional + `AppError` return shape. + ## [0.20.3] - 2025-10-03 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index e9bdc54..92e21e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.3" +version = "0.20.4" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 23b8ad6..641a46b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.3" +version = "0.20.4" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 5e30d22..9cbec41 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.20.3", default-features = false } +masterror = { version = "0.20.4", default-features = false } # or with features: -# masterror = { version = "0.20.3", features = [ +# masterror = { version = "0.20.4", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.20.3", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.20.3", default-features = false } +masterror = { version = "0.20.4", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.20.3", features = [ +# masterror = { version = "0.20.4", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.20.3", default-features = false } +masterror = { version = "0.20.4", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.20.3", features = [ +masterror = { version = "0.20.4", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.20.3", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.20.3", features = [ +masterror = { version = "0.20.4", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/code.rs b/src/code.rs index 8ce7783..b9dc44a 100644 --- a/src/code.rs +++ b/src/code.rs @@ -69,4 +69,4 @@ mod app_code; -pub use app_code::AppCode; +pub use app_code::{AppCode, ParseAppCodeError}; diff --git a/src/code/app_code.rs b/src/code/app_code.rs index b46d699..9afc1f3 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -1,4 +1,8 @@ -use std::fmt::{self, Display}; +use std::{ + error::Error as StdError, + fmt::{self, Display}, + str::FromStr +}; use serde::{Deserialize, Serialize}; #[cfg(feature = "openapi")] @@ -6,6 +10,21 @@ use utoipa::ToSchema; use crate::kind::AppErrorKind; +/// Error returned when parsing [`AppCode`] from a string fails. +/// +/// The parser only accepts the canonical SCREAMING_SNAKE_CASE representations +/// emitted by [`AppCode::as_str`]. Any other value results in this error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseAppCodeError; + +impl Display for ParseAppCodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("invalid app code") + } +} + +impl StdError for ParseAppCodeError {} + /// Stable machine-readable error code exposed to clients. /// /// Values are serialized as **SCREAMING_SNAKE_CASE** strings (e.g., @@ -188,6 +207,59 @@ impl Display for AppCode { } } +/// Parse an [`AppCode`] from its canonical string representation. +/// +/// # Errors +/// +/// Returns [`ParseAppCodeError`] when the input does not match any known code. +/// +/// # Examples +/// ``` +/// use std::str::FromStr; +/// +/// use masterror::{AppCode, ParseAppCodeError}; +/// +/// let code = AppCode::from_str("NOT_FOUND")?; +/// assert_eq!(code, AppCode::NotFound); +/// # Ok::<(), ParseAppCodeError>(()) +/// ``` +impl FromStr for AppCode { + type Err = ParseAppCodeError; + + fn from_str(s: &str) -> Result { + match s { + // 4xx + "NOT_FOUND" => Ok(Self::NotFound), + "VALIDATION" => Ok(Self::Validation), + "CONFLICT" => Ok(Self::Conflict), + "USER_ALREADY_EXISTS" => Ok(Self::UserAlreadyExists), + "UNAUTHORIZED" => Ok(Self::Unauthorized), + "FORBIDDEN" => Ok(Self::Forbidden), + "NOT_IMPLEMENTED" => Ok(Self::NotImplemented), + "BAD_REQUEST" => Ok(Self::BadRequest), + "RATE_LIMITED" => Ok(Self::RateLimited), + "TELEGRAM_AUTH" => Ok(Self::TelegramAuth), + "INVALID_JWT" => Ok(Self::InvalidJwt), + + // 5xx + "INTERNAL" => Ok(Self::Internal), + "DATABASE" => Ok(Self::Database), + "SERVICE" => Ok(Self::Service), + "CONFIG" => Ok(Self::Config), + "TURNKEY" => Ok(Self::Turnkey), + "TIMEOUT" => Ok(Self::Timeout), + "NETWORK" => Ok(Self::Network), + "DEPENDENCY_UNAVAILABLE" => Ok(Self::DependencyUnavailable), + "SERIALIZATION" => Ok(Self::Serialization), + "DESERIALIZATION" => Ok(Self::Deserialization), + "EXTERNAL_API" => Ok(Self::ExternalApi), + "QUEUE" => Ok(Self::Queue), + "CACHE" => Ok(Self::Cache), + _ => Err(ParseAppCodeError) + } + } +} + impl From for AppCode { /// Map internal taxonomy (`AppErrorKind`) to public machine code /// (`AppCode`). @@ -227,7 +299,9 @@ impl From for AppCode { #[cfg(test)] mod tests { - use super::{AppCode, AppErrorKind}; + use std::str::FromStr; + + use super::{AppCode, AppErrorKind, ParseAppCodeError}; #[test] fn as_str_matches_json_serde_names() { @@ -264,4 +338,24 @@ mod tests { fn display_uses_screaming_snake_case() { assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST"); } + + #[test] + fn from_str_parses_known_codes() { + for code in [ + AppCode::NotFound, + AppCode::Validation, + AppCode::Unauthorized, + AppCode::Internal, + AppCode::Timeout + ] { + let parsed = AppCode::from_str(code.as_str()).expect("parse"); + assert_eq!(parsed, code); + } + } + + #[test] + fn from_str_rejects_unknown_code() { + let err = AppCode::from_str("NOT_A_REAL_CODE").unwrap_err(); + assert_eq!(err, ParseAppCodeError); + } } diff --git a/src/convert/axum.rs b/src/convert/axum.rs index 61d501e..d0e5af5 100644 --- a/src/convert/axum.rs +++ b/src/convert/axum.rs @@ -37,7 +37,6 @@ //! - This module does not expose internal error sources; only `kind`, `status`, //! and optional public `message` are surfaced. -#![cfg(feature = "axum")] #![cfg_attr(docsrs, doc(cfg(feature = "axum")))] use axum::{ @@ -74,7 +73,7 @@ mod tests { use axum::http::StatusCode; use super::*; - use crate::{AppCode, AppErrorKind}; + use crate::AppCode; // --- http_status mapping ------------------------------------------------- @@ -101,7 +100,7 @@ mod tests { 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(); + let resp = app_err.into_response(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); @@ -156,7 +155,7 @@ mod tests { use axum::{body::to_bytes, response::IntoResponse}; let app_err = AppError::internal("secret").redactable(); - let mut resp = app_err.into_response(); + let resp = app_err.into_response(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index ced79fb..d16f549 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -216,7 +216,6 @@ fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, O } let mut retry_after = None; - let mut category = AppErrorKind::Database; let mut code_override = None; let code = error.code().map(|code| code.into_owned()); @@ -236,7 +235,7 @@ fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, O } } - category = match error.kind() { + let category = match error.kind() { SqlxErrorKind::UniqueViolation => AppErrorKind::Conflict, SqlxErrorKind::ForeignKeyViolation => AppErrorKind::Conflict, SqlxErrorKind::NotNullViolation | SqlxErrorKind::CheckViolation => { diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 677e378..9328132 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -119,7 +119,7 @@ fn metadata_value_to_ascii(value: &FieldValue) -> Option> { match value { FieldValue::Str(value) => { let text = value.as_ref(); - is_ascii_metadata_value(text).then(|| Cow::Borrowed(text)) + is_ascii_metadata_value(text).then_some(Cow::Borrowed(text)) } FieldValue::I64(value) => Some(Cow::Owned(value.to_string())), FieldValue::U64(value) => Some(Cow::Owned(value.to_string())), diff --git a/src/lib.rs b/src/lib.rs index 0f912bd..1bcac14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -326,7 +326,7 @@ pub use app_error::{ AppError, AppResult, Context, Error, Field, FieldRedaction, FieldValue, MessageEditPolicy, Metadata, field }; -pub use code::AppCode; +pub use code::{AppCode, ParseAppCodeError}; pub use kind::AppErrorKind; /// Re-export derive macros so users only depend on this crate. /// diff --git a/src/response/details.rs b/src/response/details.rs index ceca799..faa699e 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -54,6 +54,7 @@ impl ErrorResponse { /// assert!(resp.details.is_some()); /// # } /// ``` + #[allow(clippy::result_large_err)] pub fn with_details(self, payload: T) -> AppResult where T: Serialize