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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ tracing = "0.1"

serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
http = "1"

# опциональные интеграции
axum = { version = "0.8", optional = true, default-features = false, features = [
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ async fn err() -> AppResult<&'static str> {
#[get("/payload")]
async fn payload() -> impl Responder {
ErrorResponse::new(422, AppCode::Validation, "Validation failed")
.expect("status")
}
~~~

Expand Down
2 changes: 1 addition & 1 deletion src/code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
//! ```rust
//! use masterror::{AppCode, ErrorResponse};
//!
//! let resp = ErrorResponse::new(404, AppCode::NotFound, "User not found");
//! let resp = ErrorResponse::new(404, AppCode::NotFound, "User not found").expect("status");
//! ```
//!
//! Convert from internal taxonomy (`AppErrorKind`) to a public code:
Expand Down
13 changes: 6 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,17 @@
//! also pulled transitively by `axum`
//! - `multipart` — compatibility flag for Axum multipart
//! - `turnkey` — domain taxonomy and conversions for Turnkey errors, exposed in
//! the [`turnkey`] module
//! the `turnkey` module
//!
//! # Domain integrations: Turnkey
//!
//! With the `turnkey` feature enabled, the crate exports a [`turnkey`] module
//! With the `turnkey` feature enabled, the crate exports a `turnkey` module
//! that provides:
//!
//! - [`turnkey::TurnkeyErrorKind`] — stable categories for Turnkey-specific
//! - `turnkey::TurnkeyErrorKind` — stable categories for Turnkey-specific
//! failures
//! - [`turnkey::TurnkeyError`] — a container with `kind` and safe, public
//! message
//! - [`turnkey::classify_turnkey_error`] — heuristic classifier for raw
//! - `turnkey::TurnkeyError` — a container with `kind` and safe, public message
//! - `turnkey::classify_turnkey_error` — heuristic classifier for raw
//! SDK/provider strings
//! - conversions: `From<TurnkeyError>` → [`AppError`] and
//! `From<TurnkeyErrorKind>` → [`AppErrorKind`]
Expand Down Expand Up @@ -112,7 +111,7 @@
//! ```rust
//! use masterror::{AppCode, ErrorResponse};
//!
//! let resp = ErrorResponse::new(404, AppCode::NotFound, "User not found");
//! let resp = ErrorResponse::new(404, AppCode::NotFound, "User not found").expect("status");
//! ```
//!
//! Conversion from [`AppError`]:
Expand Down
56 changes: 44 additions & 12 deletions src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@
//! ```rust
//! use masterror::{AppCode, ErrorResponse};
//!
//! let resp =
//! ErrorResponse::new(404, AppCode::NotFound, "User not found").with_retry_after_secs(30);
//! let resp = ErrorResponse::new(404, AppCode::NotFound, "User not found")
//! .expect("status")
//! .with_retry_after_secs(30);
//! ```
//!
//! With `serde_json` enabled:
Expand All @@ -42,6 +43,7 @@
//! use serde_json::json;
//!
//! let resp = ErrorResponse::new(422, AppCode::Validation, "Invalid input")
//! .expect("status")
//! .with_details_json(json!({"field": "email", "error": "invalid"}));
//! # }
//! ```
Expand All @@ -55,13 +57,17 @@

use std::fmt::{Display, Formatter, Result as FmtResult};

use http::StatusCode;
use serde::{Deserialize, Serialize};
#[cfg(feature = "serde_json")]
use serde_json::Value as JsonValue;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;

use crate::{app_error::AppError, code::AppCode};
use crate::{
app_error::{AppError, AppResult},
code::AppCode
};

/// Retry advice intended for API clients.
///
Expand Down Expand Up @@ -114,16 +120,21 @@ pub struct ErrorResponse {
impl ErrorResponse {
/// Construct a new [`ErrorResponse`] with a status code, a stable
/// [`AppCode`], and a public message.
#[must_use]
pub fn new(status: u16, code: AppCode, message: impl Into<String>) -> Self {
Self {
///
/// # Errors
///
/// Returns [`AppError`] if `status` is not a valid HTTP status code.
pub fn new(status: u16, code: AppCode, message: impl Into<String>) -> AppResult<Self> {
StatusCode::from_u16(status)
.map_err(|_| AppError::bad_request(format!("invalid HTTP status: {status}")))?;
Ok(Self {
status,
code,
message: message.into(),
details: None,
retry: None,
www_authenticate: None
}
})
}

/// Attach plain-text details (available when `serde_json` is disabled).
Expand Down Expand Up @@ -175,7 +186,15 @@ impl ErrorResponse {
/// ease migration from versions prior to 0.3.0.
#[must_use]
pub fn new_legacy(status: u16, message: impl Into<String>) -> Self {
Self::new(status, AppCode::Internal, message)
let msg = message.into();
Self::new(status, AppCode::Internal, msg.clone()).unwrap_or(Self {
status: 500,
code: AppCode::Internal,
message: msg,
details: None,
retry: None,
www_authenticate: None
})
}
}

Expand Down Expand Up @@ -316,17 +335,24 @@ mod tests {

#[test]
fn new_sets_status_code_and_message() {
let e = ErrorResponse::new(404, AppCode::NotFound, "missing");
let e = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status");
assert_eq!(e.status, 404);
assert!(matches!(e.code, AppCode::NotFound));
assert_eq!(e.message, "missing");
assert!(e.retry.is_none());
assert!(e.www_authenticate.is_none());
}

#[test]
fn new_rejects_invalid_status() {
let err = ErrorResponse::new(0, AppCode::Internal, "boom").expect_err("invalid");
assert!(matches!(err.kind, AppErrorKind::BadRequest));
}

#[test]
fn with_retry_and_www_authenticate_attach_metadata() {
let e = ErrorResponse::new(401, AppCode::Unauthorized, "auth required")
.expect("status")
.with_retry_after_secs(15)
.with_www_authenticate(r#"Bearer realm="api""#);
assert_eq!(e.status, 401);
Expand All @@ -341,6 +367,7 @@ mod tests {
fn details_json_are_attached() {
let payload = serde_json::json!({"field": "email", "error": "invalid"});
let e = ErrorResponse::new(422, AppCode::Validation, "invalid")
.expect("status")
.with_details_json(payload.clone());
assert_eq!(e.status, 422);
assert!(e.details.is_some());
Expand All @@ -351,6 +378,7 @@ mod tests {
#[test]
fn details_text_are_attached() {
let e = ErrorResponse::new(503, AppCode::DependencyUnavailable, "down")
.expect("status")
.with_details_text("retry later");
assert_eq!(e.status, 503);
assert_eq!(e.details.as_deref(), Some("retry later"));
Expand Down Expand Up @@ -381,7 +409,7 @@ mod tests {

#[test]
fn display_is_concise_and_does_not_leak_details() {
let e = ErrorResponse::new(400, AppCode::BadRequest, "bad");
let e = ErrorResponse::new(400, AppCode::BadRequest, "bad").expect("status");
let s = format!("{}", e);
assert!(s.contains("400"), "status should be present");
assert!(
Expand Down Expand Up @@ -415,6 +443,7 @@ mod tests {
};

let resp = ErrorResponse::new(401, AppCode::Unauthorized, "no token")
.expect("status")
.with_retry_after_secs(7)
.with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#)
.into_response();
Expand Down Expand Up @@ -444,6 +473,7 @@ mod tests {

// Build ErrorResponse with both headers
let resp = ErrorResponse::new(429, AppCode::RateLimited, "slow down")
.expect("status")
.with_retry_after_secs(42)
.with_www_authenticate("Bearer");

Expand All @@ -468,7 +498,7 @@ mod tests {
test::TestRequest
};

let resp = ErrorResponse::new(500, AppCode::Internal, "boom");
let resp = ErrorResponse::new(500, AppCode::Internal, "boom").expect("status");
let req = TestRequest::default().to_http_request();
let http = resp.respond_to(&req);

Expand All @@ -482,7 +512,9 @@ mod tests {
#[cfg(feature = "serde_json")]
#[test]
fn serialized_json_contains_core_fields() {
let e = ErrorResponse::new(404, AppCode::NotFound, "nope").with_retry_after_secs(1);
let e = ErrorResponse::new(404, AppCode::NotFound, "nope")
.expect("status")
.with_retry_after_secs(1);
let s = serde_json::to_string(&e).expect("serialize");
// Fast contract sanity checks without tying to exact field order
assert!(s.contains("\"status\":404"));
Expand Down