Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
] }
Expand All @@ -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"
] }
Expand Down
4 changes: 4 additions & 0 deletions src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 20 additions & 5 deletions src/convert/actix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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(())
}
}
37 changes: 25 additions & 12 deletions src/convert/axum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ impl IntoResponse for AppError {

#[cfg(test)]
mod tests {
use std::str::FromStr;

use axum::http::StatusCode;

use super::*;
Expand Down Expand Up @@ -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]
Expand All @@ -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());
}
}
2 changes: 1 addition & 1 deletion src/convert/multipart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
5 changes: 3 additions & 2 deletions src/convert/redis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ impl From<RedisError> for Error {
fn build_context(err: &RedisError) -> (Context, Option<u64>) {
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",
Expand Down Expand Up @@ -115,7 +115,8 @@ const fn retry_method_details(method: RetryMethod) -> (&'static str, Option<u64>
RetryMethod::ReconnectFromInitialConnections => {
("ReconnectFromInitialConnections", Some(1))
}
RetryMethod::WaitAndRetry => ("WaitAndRetry", Some(2))
RetryMethod::WaitAndRetry => ("WaitAndRetry", Some(2)),
_ => ("Other", None)
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/convert/reqwest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ fn classify_reqwest_error(err: &ReqwestError) -> (Context, Option<u64>) {

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() {
Expand Down
5 changes: 3 additions & 2 deletions src/convert/sqlx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/convert/tonic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ fn insert_ascii(meta: &mut MetadataMap, key: &'static str, value: impl AsRef<str
if !is_ascii_metadata_value(value) {
return;
}
if let Ok(metadata_value) = MetadataValue::try_from(value.as_ref()) {
if let Ok(metadata_value) = MetadataValue::try_from(value) {
let _ = meta.insert(key, metadata_value);
}
}
Expand Down
14 changes: 7 additions & 7 deletions src/response/problem_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ pub struct GrpcCode {
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>,
pub type_uri: Option<Cow<'static, str>>,
/// Short, human-friendly title describing the error category.
pub title: Cow<'static, str>,
/// HTTP status code returned to the client.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -284,7 +284,7 @@ impl ProblemJson {
/// ```
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct ProblemMetadata(BTreeMap<&'static str, ProblemMetadataValue>);
pub struct ProblemMetadata(BTreeMap<Cow<'static, str>, ProblemMetadataValue>);

impl ProblemMetadata {
#[cfg(test)]
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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);
}
}

Expand Down
Loading