From a592ededcf30751ef43d54a2833b12e28a20d40d Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:44:50 +0700 Subject: [PATCH] Fix feature regressions and prepare 0.20.3 --- CHANGELOG.md | 13 +++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 +++++++------- src/convert.rs | 4 ++++ src/convert/actix.rs | 25 +++++++++++++++++++----- src/convert/axum.rs | 37 ++++++++++++++++++++++++------------ src/convert/multipart.rs | 2 +- src/convert/redis.rs | 5 +++-- src/convert/reqwest.rs | 2 +- src/convert/sqlx.rs | 5 +++-- src/convert/tonic.rs | 2 +- src/response/problem_json.rs | 14 +++++++------- 13 files changed, 87 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0b0af..2f5377d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.3] - 2025-10-03 + +### Fixed +- Restored the Axum transport adapter in builds by wiring the `convert::axum` + module into the crate graph and relaxing the tests to validate responses via + `serde_json::Value` instead of requiring `ProblemJson` deserialization. +- Hardened converter telemetry for Redis, Reqwest, SQLx, Tonic and multipart + integrations by owning metadata strings where necessary and covering + non-exhaustive enums so the crate compiles cleanly on Rust 1.90. +- Reworked `ProblemJson` metadata internals to use `Cow<'static, str>` keys and + values, preserving zero-copy behaviour for borrowed data while allowing owned + fallbacks required by the updated converters. + ## [0.20.2] - 2025-10-02 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index bba324d..e9bdc54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.2" +version = "0.20.3" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 236991d..23b8ad6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.2" +version = "0.20.3" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 4bedfff..5e30d22 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.2", default-features = false } +masterror = { version = "0.20.3", default-features = false } # or with features: -# masterror = { version = "0.20.2", features = [ +# masterror = { version = "0.20.3", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.20.2", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.20.2", default-features = false } +masterror = { version = "0.20.3", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.20.2", features = [ +# masterror = { version = "0.20.3", 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.2", default-features = false } +masterror = { version = "0.20.3", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.20.2", features = [ +masterror = { version = "0.20.3", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.20.2", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.20.2", features = [ +masterror = { version = "0.20.3", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/convert.rs b/src/convert.rs index 7b74603..bb409a5 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -78,6 +78,10 @@ use std::io::Error as IoError; use crate::AppError; +#[cfg(feature = "axum")] +#[cfg_attr(docsrs, doc(cfg(feature = "axum")))] +mod axum; + #[cfg(all(feature = "axum", feature = "multipart"))] #[cfg_attr(docsrs, doc(cfg(all(feature = "axum", feature = "multipart"))))] mod multipart; diff --git a/src/convert/actix.rs b/src/convert/actix.rs index f4abbb0..b1c34f7 100644 --- a/src/convert/actix.rs +++ b/src/convert/actix.rs @@ -98,13 +98,15 @@ impl ResponseError for AppError { #[cfg(all(test, feature = "actix"))] mod actix_tests { + use std::str::FromStr; + use actix_web::{ ResponseError, body::to_bytes, http::header::{RETRY_AFTER, WWW_AUTHENTICATE} }; - use crate::{AppCode, AppError, AppErrorKind, AppResult, ProblemJson}; + use crate::{AppCode, AppError, AppErrorKind, AppResult}; #[test] fn maps_status_consistently() { @@ -132,10 +134,23 @@ mod actix_tests { ); let bytes = to_bytes(resp.into_body()).await?; - let body: ProblemJson = serde_json::from_slice(&bytes)?; - assert_eq!(body.status, 401); - assert!(matches!(body.code, AppCode::Unauthorized)); - assert_eq!(body.detail.as_deref(), Some("no token")); + let body: serde_json::Value = serde_json::from_slice(&bytes)?; + assert_eq!( + body.get("status").and_then(|value| value.as_u64()), + Some(401) + ); + assert_eq!( + body.get("code") + .and_then(|value| value.as_str()) + .map(AppCode::from_str) + .transpose() + .expect("parse app code"), + Some(AppCode::Unauthorized) + ); + assert_eq!( + body.get("detail").and_then(|value| value.as_str()), + Some("no token") + ); Ok(()) } } diff --git a/src/convert/axum.rs b/src/convert/axum.rs index 14abf21..61d501e 100644 --- a/src/convert/axum.rs +++ b/src/convert/axum.rs @@ -69,6 +69,8 @@ impl IntoResponse for AppError { #[cfg(test)] mod tests { + use std::str::FromStr; + use axum::http::StatusCode; use super::*; @@ -127,14 +129,26 @@ mod tests { let bytes = to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); - 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.detail.as_deref(), Some("missing token")); - assert!(body.metadata.is_none()); - assert!(body.grpc.is_some()); + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); + + assert_eq!( + body.get("status").and_then(|value| value.as_u64()), + Some(401) + ); + assert_eq!( + body.get("code") + .and_then(|value| value.as_str()) + .map(AppCode::from_str) + .transpose() + .expect("parse app code"), + Some(AppCode::Unauthorized) + ); + assert_eq!( + body.get("detail").and_then(|value| value.as_str()), + Some("missing token") + ); + assert!(body.get("metadata").is_none()); + assert!(body.get("grpc").is_some()); } #[tokio::test] @@ -149,10 +163,9 @@ mod tests { let bytes = to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); - let body: crate::response::ProblemJson = - serde_json::from_slice(&bytes).expect("json body"); + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); - assert!(body.detail.is_none()); - assert!(body.metadata.is_none()); + assert!(body.get("detail").is_none()); + assert!(body.get("metadata").is_none()); } } diff --git a/src/convert/multipart.rs b/src/convert/multipart.rs index 890c24e..64510a3 100644 --- a/src/convert/multipart.rs +++ b/src/convert/multipart.rs @@ -38,7 +38,7 @@ mod tests { http::Request }; - use crate::{AppErrorKind, FieldValue}; + use crate::{AppErrorKind, Error, FieldValue}; #[tokio::test] async fn multipart_error_maps_to_bad_request() { diff --git a/src/convert/redis.rs b/src/convert/redis.rs index 919de00..fb7511d 100644 --- a/src/convert/redis.rs +++ b/src/convert/redis.rs @@ -59,7 +59,7 @@ impl From for Error { fn build_context(err: &RedisError) -> (Context, Option) { let mut context = Context::new(AppErrorKind::Cache) .with(field::str("redis.kind", format!("{:?}", err.kind()))) - .with(field::str("redis.category", err.category())) + .with(field::str("redis.category", err.category().to_owned())) .with(field::bool("redis.is_timeout", err.is_timeout())) .with(field::bool( "redis.is_cluster_error", @@ -115,7 +115,8 @@ const fn retry_method_details(method: RetryMethod) -> (&'static str, Option RetryMethod::ReconnectFromInitialConnections => { ("ReconnectFromInitialConnections", Some(1)) } - RetryMethod::WaitAndRetry => ("WaitAndRetry", Some(2)) + RetryMethod::WaitAndRetry => ("WaitAndRetry", Some(2)), + _ => ("Other", None) } } diff --git a/src/convert/reqwest.rs b/src/convert/reqwest.rs index d79324f..5fa756e 100644 --- a/src/convert/reqwest.rs +++ b/src/convert/reqwest.rs @@ -107,7 +107,7 @@ fn classify_reqwest_error(err: &ReqwestError) -> (Context, Option) { if let Some(url) = err.url() { context = context - .with(field::str("http.url", url.as_str())) + .with(field::str("http.url", url.to_string())) .redact_field("http.url", FieldRedaction::Hash); if let Some(host) = url.host_str() { diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index b8a377a..ced79fb 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -242,7 +242,7 @@ fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, O SqlxErrorKind::NotNullViolation | SqlxErrorKind::CheckViolation => { AppErrorKind::Validation } - SqlxErrorKind::Other => AppErrorKind::Database + _ => AppErrorKind::Database }; context = context.category(category); @@ -420,7 +420,8 @@ mod tests_sqlx { SqlxErrorKind::ForeignKeyViolation => SqlxErrorKind::ForeignKeyViolation, SqlxErrorKind::NotNullViolation => SqlxErrorKind::NotNullViolation, SqlxErrorKind::CheckViolation => SqlxErrorKind::CheckViolation, - SqlxErrorKind::Other => SqlxErrorKind::Other + SqlxErrorKind::Other => SqlxErrorKind::Other, + _ => SqlxErrorKind::Other } } } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 8a567bf..677e378 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -110,7 +110,7 @@ fn insert_ascii(meta: &mut MetadataMap, key: &'static str, value: impl AsRef, + pub type_uri: Option>, /// Short, human-friendly title describing the error category. pub title: Cow<'static, str>, /// HTTP status code returned to the client. @@ -159,7 +159,7 @@ impl ProblemJson { let metadata = sanitize_metadata_owned(metadata, edit_policy); Self { - type_uri: Some(mapping.problem_type()), + type_uri: Some(Cow::Borrowed(mapping.problem_type())), title, status, detail, @@ -195,7 +195,7 @@ impl ProblemJson { let metadata = sanitize_metadata_ref(error.metadata(), error.edit_policy); Self { - type_uri: Some(mapping.problem_type()), + type_uri: Some(Cow::Borrowed(mapping.problem_type())), title, status, detail, @@ -231,7 +231,7 @@ impl ProblemJson { }; Self { - type_uri: Some(mapping.problem_type()), + type_uri: Some(Cow::Borrowed(mapping.problem_type())), title: Cow::Owned(mapping.kind().to_string()), status: response.status, detail, @@ -284,7 +284,7 @@ impl ProblemJson { /// ``` #[derive(Clone, Debug, Serialize)] #[serde(transparent)] -pub struct ProblemMetadata(BTreeMap<&'static str, ProblemMetadataValue>); +pub struct ProblemMetadata(BTreeMap, ProblemMetadataValue>); impl ProblemMetadata { #[cfg(test)] @@ -373,7 +373,7 @@ fn sanitize_metadata_owned( for field in metadata { let (name, value, redaction) = field.into_parts(); if let Some(sanitized) = sanitize_problem_metadata_value_owned(value, redaction) { - public.insert(name, sanitized); + public.insert(Cow::Borrowed(name), sanitized); } } @@ -395,7 +395,7 @@ fn sanitize_metadata_ref( let mut public = BTreeMap::new(); for (name, value, redaction) in metadata.iter_with_redaction() { if let Some(sanitized) = sanitize_problem_metadata_value_ref(value, redaction) { - public.insert(name, sanitized); + public.insert(Cow::Borrowed(name), sanitized); } }