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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.3"
version = "0.20.4"
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.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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
] }
Expand All @@ -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"
] }
Expand Down
2 changes: 1 addition & 1 deletion src/code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,4 @@

mod app_code;

pub use app_code::AppCode;
pub use app_code::{AppCode, ParseAppCodeError};
98 changes: 96 additions & 2 deletions src/code/app_code.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
use std::fmt::{self, Display};
use std::{
error::Error as StdError,
fmt::{self, Display},
str::FromStr
};

use serde::{Deserialize, Serialize};
#[cfg(feature = "openapi")]
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.,
Expand Down Expand Up @@ -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<Self, Self::Err> {
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<AppErrorKind> for AppCode {
/// Map internal taxonomy (`AppErrorKind`) to public machine code
/// (`AppCode`).
Expand Down Expand Up @@ -227,7 +299,9 @@ impl From<AppErrorKind> 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() {
Expand Down Expand Up @@ -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);
}
}
7 changes: 3 additions & 4 deletions src/convert/axum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -74,7 +73,7 @@ mod tests {
use axum::http::StatusCode;

use super::*;
use crate::{AppCode, AppErrorKind};
use crate::AppCode;

// --- http_status mapping -------------------------------------------------

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

Expand Down Expand Up @@ -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);

Expand Down
3 changes: 1 addition & 2 deletions src/convert/sqlx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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 => {
Expand Down
2 changes: 1 addition & 1 deletion src/convert/tonic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ fn metadata_value_to_ascii(value: &FieldValue) -> Option<Cow<'_, str>> {
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())),
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
1 change: 1 addition & 0 deletions src/response/details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ impl ErrorResponse {
/// assert!(resp.details.is_some());
/// # }
/// ```
#[allow(clippy::result_large_err)]
pub fn with_details<T>(self, payload: T) -> AppResult<Self>
where
T: Serialize
Expand Down
Loading