From 7e98007d9b571612b6c216b34d6c331527f36c33 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:09:33 +0700 Subject: [PATCH] Validate status code in ErrorResponse --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 1 + src/code.rs | 2 +- src/lib.rs | 13 ++++++------ src/response.rs | 56 ++++++++++++++++++++++++++++++++++++++----------- 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f6bff5..44ecfbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1224,6 +1224,7 @@ dependencies = [ "actix-web", "axum", "config", + "http 1.3.1", "redis", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index afa66cd..90ec832 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [ diff --git a/README.md b/README.md index abb64ed..a2237b0 100644 --- a/README.md +++ b/README.md @@ -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") } ~~~ diff --git a/src/code.rs b/src/code.rs index cbfef13..c975069 100644 --- a/src/code.rs +++ b/src/code.rs @@ -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: diff --git a/src/lib.rs b/src/lib.rs index 07dee1e..f71625a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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` → [`AppError`] and //! `From` → [`AppErrorKind`] @@ -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`]: diff --git a/src/response.rs b/src/response.rs index f1bae48..dc90e5f 100644 --- a/src/response.rs +++ b/src/response.rs @@ -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: @@ -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"})); //! # } //! ``` @@ -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. /// @@ -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) -> Self { - Self { + /// + /// # Errors + /// + /// Returns [`AppError`] if `status` is not a valid HTTP status code. + pub fn new(status: u16, code: AppCode, message: impl Into) -> AppResult { + 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). @@ -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) -> 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 + }) } } @@ -316,7 +335,7 @@ 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"); @@ -324,9 +343,16 @@ mod tests { 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); @@ -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()); @@ -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")); @@ -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!( @@ -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(); @@ -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"); @@ -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); @@ -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"));