diff --git a/README.md b/README.md index 894889e..50a1d0c 100644 --- a/README.md +++ b/README.md @@ -329,4 +329,3 @@ MSRV = 1.89 (may raise in minor, never in patch). Apache-2.0 OR MIT, at your option. - diff --git a/src/app_error.rs b/src/app_error.rs index 260d3b4..d76600f 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -57,587 +57,10 @@ //! `kind`, `code` and optional `message` fields. Prefer calling it at the //! transport boundary (e.g. in `IntoResponse`) to avoid duplicate logs. -use std::borrow::Cow; +mod constructors; +mod core; -use tracing::error; - -use crate::{Error, RetryAdvice, code::AppCode, kind::AppErrorKind}; - -/// Thin error wrapper: kind + optional message. -/// -/// `Display` prints only the `kind`. The optional `message` is intended for -/// logs and (when appropriate) public JSON payloads. Keep messages concise and -/// free of sensitive data. -#[derive(Debug, Error)] -#[error("{kind}")] -pub struct AppError { - /// Semantic category of the error. - pub kind: AppErrorKind, - /// Optional, public-friendly message. - pub message: Option>, - /// Optional retry advice rendered as `Retry-After`. - pub retry: Option, - /// Optional authentication challenge for `WWW-Authenticate`. - pub www_authenticate: Option -} - -/// Conventional result alias for application code. -/// -/// The alias defaults to [`AppError`] but accepts a custom error type when the -/// context requires a different domain error. -/// -/// # Examples -/// -/// ```rust -/// use std::io::Error; -/// -/// use masterror::AppResult; -/// -/// fn app_logic() -> AppResult { -/// Ok(7) -/// } -/// -/// fn io_logic() -> AppResult<(), Error> { -/// Ok(()) -/// } -/// -/// assert_eq!(app_logic().unwrap(), 7); -/// assert!(io_logic().is_ok()); -/// ``` -pub type AppResult = Result; - -impl AppError { - /// Create a new [`AppError`] with a kind and message. - /// - /// This is equivalent to [`AppError::with`], provided for API symmetry and - /// to keep doctests readable. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::new(AppErrorKind::BadRequest, "invalid payload"); - /// assert!(err.message.is_some()); - /// ``` - pub fn new(kind: AppErrorKind, msg: impl Into>) -> Self { - Self::with(kind, msg) - } - - /// Create an error with the given kind and message. - /// - /// Prefer named helpers (e.g. [`AppError::not_found`]) where it clarifies - /// intent. - pub fn with(kind: AppErrorKind, msg: impl Into>) -> Self { - Self { - kind, - message: Some(msg.into()), - retry: None, - www_authenticate: None - } - } - - /// Create a message-less error with the given kind. - /// - /// Useful when the kind alone conveys sufficient information to the client. - pub fn bare(kind: AppErrorKind) -> Self { - Self { - kind, - message: None, - retry: None, - www_authenticate: None - } - } - - /// Attach retry advice to the error. - /// - /// When mapped to HTTP, this becomes the `Retry-After` header. - #[must_use] - pub fn with_retry_after_secs(mut self, secs: u64) -> Self { - self.retry = Some(RetryAdvice { - after_seconds: secs - }); - self - } - - /// Attach a `WWW-Authenticate` challenge string. - #[must_use] - pub fn with_www_authenticate(mut self, value: impl Into) -> Self { - self.www_authenticate = Some(value.into()); - self - } - - /// Log the error once at the boundary with stable fields. - /// - /// Emits a `tracing::error!` with `kind`, `code` and optional `message`. - /// No internals or sources are leaked. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::internal("boom"); - /// // In production, call this at the boundary (e.g. HTTP mapping). - /// err.log(); - /// ``` - pub fn log(&self) { - let code = AppCode::from(self.kind); - match &self.message { - Some(m) => error!(kind = ?self.kind, code = %code, message = %m), - None => error!(kind = ?self.kind, code = %code) - } - } - - // --- Canonical constructors (keep in sync with AppErrorKind) ------------- - - // 4xx-ish - /// Build a `NotFound` error. - pub fn not_found(msg: impl Into>) -> Self { - Self::with(AppErrorKind::NotFound, msg) - } - /// Build a `Validation` error. - pub fn validation(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Validation, msg) - } - /// Build an `Unauthorized` error. - pub fn unauthorized(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Unauthorized, msg) - } - /// Build a `Forbidden` error. - pub fn forbidden(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Forbidden, msg) - } - /// Build a `Conflict` error. - pub fn conflict(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Conflict, msg) - } - /// Build a `BadRequest` error. - pub fn bad_request(msg: impl Into>) -> Self { - Self::with(AppErrorKind::BadRequest, msg) - } - /// Build a `RateLimited` error. - pub fn rate_limited(msg: impl Into>) -> Self { - Self::with(AppErrorKind::RateLimited, msg) - } - /// Build a `TelegramAuth` error. - pub fn telegram_auth(msg: impl Into>) -> Self { - Self::with(AppErrorKind::TelegramAuth, msg) - } - - // 5xx-ish - /// Build an `Internal` error. - pub fn internal(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Internal, msg) - } - /// Build a `Service` error (generic server-side service failure). - pub fn service(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Service, msg) - } - /// Build a `Database` error with an optional message. - /// - /// Accepts `Option` to avoid gratuitous `.map(|...| ...)` at call sites - /// when you may or may not have a safe-to-print string at hand. - pub fn database(msg: Option>>) -> Self { - Self { - kind: AppErrorKind::Database, - message: msg.map(Into::into), - retry: None, - www_authenticate: None - } - } - /// Build a `Config` error. - pub fn config(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Config, msg) - } - /// Build a `Turnkey` error. - pub fn turnkey(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Turnkey, msg) - } - - // Infra / network - /// Build a `Timeout` error. - pub fn timeout(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Timeout, msg) - } - /// Build a `Network` error. - pub fn network(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Network, msg) - } - /// Build a `DependencyUnavailable` error. - pub fn dependency_unavailable(msg: impl Into>) -> Self { - Self::with(AppErrorKind::DependencyUnavailable, msg) - } - /// Backward-compatible alias; routes to `DependencyUnavailable`. - pub fn service_unavailable(msg: impl Into>) -> Self { - Self::with(AppErrorKind::DependencyUnavailable, msg) - } - - // Serialization / external API / subsystems - /// Build a `Serialization` error. - pub fn serialization(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Serialization, msg) - } - /// Build a `Deserialization` error. - pub fn deserialization(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Deserialization, msg) - } - /// Build an `ExternalApi` error. - pub fn external_api(msg: impl Into>) -> Self { - Self::with(AppErrorKind::ExternalApi, msg) - } - /// Build a `Queue` error. - pub fn queue(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Queue, msg) - } - /// Build a `Cache` error. - pub fn cache(msg: impl Into>) -> Self { - Self::with(AppErrorKind::Cache, msg) - } -} +pub use core::{AppError, AppResult}; #[cfg(test)] -mod tests { - use super::{AppError, AppErrorKind, AppResult}; - - // --- Helpers ------------------------------------------------------------- - - /// Assert helper: kind matches and message is Some(s). - fn assert_err_with_msg(err: AppError, expected: AppErrorKind, msg: &str) { - assert!( - matches!(err.kind, k if k == expected), - "expected kind {:?}, got {:?}", - expected, - err.kind - ); - assert_eq!(err.message.as_deref(), Some(msg)); - } - - /// Assert helper: kind matches and message is None. - fn assert_err_bare(err: AppError, expected: AppErrorKind) { - assert!( - matches!(err.kind, k if k == expected), - "expected kind {:?}, got {:?}", - expected, - err.kind - ); - assert!(err.message.is_none(), "expected no message"); - } - - // --- Constructors: generic ---------------------------------------------- - - #[test] - fn new_and_with_attach_message() { - let e1 = AppError::new(AppErrorKind::BadRequest, "invalid payload"); - assert_err_with_msg(e1, AppErrorKind::BadRequest, "invalid payload"); - - let e2 = AppError::with(AppErrorKind::Forbidden, "no access"); - assert_err_with_msg(e2, AppErrorKind::Forbidden, "no access"); - } - - #[test] - fn bare_sets_only_kind() { - let e = AppError::bare(AppErrorKind::NotFound); - assert_err_bare(e, AppErrorKind::NotFound); - } - - // --- Display formatting -------------------------------------------------- - - #[test] - fn display_prints_kind_only() { - // AppError's Display is "{kind}", message must not appear. - let e = AppError::new(AppErrorKind::Validation, "email invalid"); - let shown = format!("{}", e); - // AppErrorKind::Validation Display text is defined on the enum via our - // `#[derive(Error)]`. We only assert that message is not leaked. - assert!( - !shown.contains("email invalid"), - "Display must not include the public message" - ); - - // Spot-check kind text presence: should include "Validation". - assert!( - shown.to_lowercase().contains("validation"), - "Display should include kind name text" - ); - } - - // --- Named helpers: 4xx -------------------------------------------------- - - #[test] - fn not_found() { - assert_err_with_msg( - AppError::not_found("missing"), - AppErrorKind::NotFound, - "missing" - ); - } - - #[test] - fn validation() { - assert_err_with_msg( - AppError::validation("bad email"), - AppErrorKind::Validation, - "bad email" - ); - } - - #[test] - fn unauthorized() { - assert_err_with_msg( - AppError::unauthorized("no token"), - AppErrorKind::Unauthorized, - "no token" - ); - } - - #[test] - fn forbidden() { - assert_err_with_msg( - AppError::forbidden("no access"), - AppErrorKind::Forbidden, - "no access" - ); - } - - #[test] - fn conflict() { - assert_err_with_msg( - AppError::conflict("version mismatch"), - AppErrorKind::Conflict, - "version mismatch" - ); - } - - #[test] - fn bad_request() { - assert_err_with_msg( - AppError::bad_request("malformed"), - AppErrorKind::BadRequest, - "malformed" - ); - } - - #[test] - fn rate_limited() { - assert_err_with_msg( - AppError::rate_limited("slow down"), - AppErrorKind::RateLimited, - "slow down" - ); - } - - #[test] - fn telegram_auth() { - assert_err_with_msg( - AppError::telegram_auth("bad hash"), - AppErrorKind::TelegramAuth, - "bad hash" - ); - } - - // --- Named helpers: 5xx and infra --------------------------------------- - - #[test] - fn internal() { - assert_err_with_msg(AppError::internal("boom"), AppErrorKind::Internal, "boom"); - } - - #[test] - fn service() { - assert_err_with_msg( - AppError::service("failed pipeline"), - AppErrorKind::Service, - "failed pipeline" - ); - } - - #[test] - fn database_some_message() { - let e = AppError::database(Some("unique violation")); - assert_err_with_msg(e, AppErrorKind::Database, "unique violation"); - } - - #[test] - fn database_no_message() { - let e = AppError::database(None::); - assert_err_bare(e, AppErrorKind::Database); - } - - #[test] - fn config() { - assert_err_with_msg(AppError::config("bad env"), AppErrorKind::Config, "bad env"); - } - - #[test] - fn turnkey() { - assert_err_with_msg( - AppError::turnkey("provider down"), - AppErrorKind::Turnkey, - "provider down" - ); - } - - #[test] - fn timeout() { - assert_err_with_msg( - AppError::timeout("deadline exceeded"), - AppErrorKind::Timeout, - "deadline exceeded" - ); - } - - #[test] - fn network() { - assert_err_with_msg(AppError::network("dns"), AppErrorKind::Network, "dns"); - } - - #[test] - fn dependency_unavailable_and_alias() { - let e = AppError::dependency_unavailable("cache down"); - assert_err_with_msg(e, AppErrorKind::DependencyUnavailable, "cache down"); - - // Alias must map to the same kind. - let alias = AppError::service_unavailable("cache down"); - assert_err_with_msg(alias, AppErrorKind::DependencyUnavailable, "cache down"); - } - - #[test] - fn serialization() { - assert_err_with_msg( - AppError::serialization("encode fail"), - AppErrorKind::Serialization, - "encode fail" - ); - } - - #[test] - fn deserialization() { - assert_err_with_msg( - AppError::deserialization("decode fail"), - AppErrorKind::Deserialization, - "decode fail" - ); - } - - #[test] - fn external_api() { - assert_err_with_msg( - AppError::external_api("upstream 502"), - AppErrorKind::ExternalApi, - "upstream 502" - ); - } - - #[test] - fn queue() { - assert_err_with_msg(AppError::queue("nack"), AppErrorKind::Queue, "nack"); - } - - #[test] - fn cache() { - assert_err_with_msg(AppError::cache("miss"), AppErrorKind::Cache, "miss"); - } - - // --- AppResult alias ----------------------------------------------------- - - #[test] - fn app_result_alias_compiles_and_matches() { - fn ok() -> AppResult { - Ok(1) - } - fn err() -> AppResult { - Err(AppError::internal("x")) - } - fn other_err() -> AppResult { - Err("boom") - } - - let a: AppResult = ok(); - let b: AppResult = err(); - let c: AppResult = other_err(); - - assert_eq!(a.unwrap(), 1); - assert!(b.is_err()); - if let Err(e) = b { - assert!(matches!(e.kind, AppErrorKind::Internal)); - } - assert_eq!(c.unwrap_err(), "boom"); - } - - // --- Logging path sanity check ------------------------------------------- - - #[test] - fn log_does_not_panic() { - // We cannot assert on tracing output here, but we can ensure no panics happen. - let e1 = AppError::internal("boom"); - e1.log(); - let e2 = AppError::bare(AppErrorKind::BadRequest); - e2.log(); - } - - #[test] - fn log_emits_code_field() { - use std::{ - fmt, - sync::{Arc, Mutex} - }; - - use tracing::{ - Event, Metadata, - field::{Field, Visit}, - span::{Attributes, Id, Record}, - subscriber::{Interest, Subscriber, with_default} - }; - - #[derive(Default)] - struct Collector { - codes: Arc>> - } - - impl Subscriber for Collector { - fn register_callsite(&self, _: &Metadata<'_>) -> Interest { - Interest::always() - } - - fn enabled(&self, _: &Metadata<'_>) -> bool { - true - } - - fn new_span(&self, _: &Attributes<'_>) -> Id { - Id::from_u64(0) - } - - fn record(&self, _: &Id, _: &Record<'_>) {} - fn record_follows_from(&self, _: &Id, _: &Id) {} - - fn event(&self, event: &Event<'_>) { - struct CodeVisitor<'a>(&'a Arc>>); - impl<'a> Visit for CodeVisitor<'a> { - fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { - if field.name() == "code" { - self.0.lock().unwrap().push(format!("{value:?}")); - } - } - fn record_str(&mut self, field: &Field, value: &str) { - if field.name() == "code" { - self.0.lock().unwrap().push(value.to_owned()); - } - } - } - let mut visitor = CodeVisitor(&self.codes); - event.record(&mut visitor); - } - - fn enter(&self, _: &Id) {} - fn exit(&self, _: &Id) {} - } - - let collector = Collector::default(); - let codes = collector.codes.clone(); - with_default(collector, || { - AppError::internal("boom").log(); - }); - - let captured = codes.lock().unwrap(); - assert!(captured.iter().any(|c| c.contains("INTERNAL"))); - } -} +mod tests; diff --git a/src/app_error/constructors.rs b/src/app_error/constructors.rs new file mode 100644 index 0000000..5dc80a2 --- /dev/null +++ b/src/app_error/constructors.rs @@ -0,0 +1,112 @@ +use std::borrow::Cow; + +use super::core::AppError; +use crate::AppErrorKind; + +impl AppError { + // --- Canonical constructors (keep in sync with AppErrorKind) ------------- + + // 4xx-ish + /// Build a `NotFound` error. + pub fn not_found(msg: impl Into>) -> Self { + Self::with(AppErrorKind::NotFound, msg) + } + /// Build a `Validation` error. + pub fn validation(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Validation, msg) + } + /// Build an `Unauthorized` error. + pub fn unauthorized(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Unauthorized, msg) + } + /// Build a `Forbidden` error. + pub fn forbidden(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Forbidden, msg) + } + /// Build a `Conflict` error. + pub fn conflict(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Conflict, msg) + } + /// Build a `BadRequest` error. + pub fn bad_request(msg: impl Into>) -> Self { + Self::with(AppErrorKind::BadRequest, msg) + } + /// Build a `RateLimited` error. + pub fn rate_limited(msg: impl Into>) -> Self { + Self::with(AppErrorKind::RateLimited, msg) + } + /// Build a `TelegramAuth` error. + pub fn telegram_auth(msg: impl Into>) -> Self { + Self::with(AppErrorKind::TelegramAuth, msg) + } + + // 5xx-ish + /// Build an `Internal` error. + pub fn internal(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Internal, msg) + } + /// Build a `Service` error (generic server-side service failure). + pub fn service(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Service, msg) + } + /// Build a `Database` error with an optional message. + /// + /// Accepts `Option` to avoid gratuitous `.map(|...| ...)` at call sites + /// when you may or may not have a safe-to-print string at hand. + pub fn database(msg: Option>>) -> Self { + Self { + kind: AppErrorKind::Database, + message: msg.map(Into::into), + retry: None, + www_authenticate: None + } + } + /// Build a `Config` error. + pub fn config(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Config, msg) + } + /// Build a `Turnkey` error. + pub fn turnkey(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Turnkey, msg) + } + + // Infra / network + /// Build a `Timeout` error. + pub fn timeout(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Timeout, msg) + } + /// Build a `Network` error. + pub fn network(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Network, msg) + } + /// Build a `DependencyUnavailable` error. + pub fn dependency_unavailable(msg: impl Into>) -> Self { + Self::with(AppErrorKind::DependencyUnavailable, msg) + } + /// Backward-compatible alias; routes to `DependencyUnavailable`. + pub fn service_unavailable(msg: impl Into>) -> Self { + Self::with(AppErrorKind::DependencyUnavailable, msg) + } + + // Serialization / external API / subsystems + /// Build a `Serialization` error. + pub fn serialization(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Serialization, msg) + } + /// Build a `Deserialization` error. + pub fn deserialization(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Deserialization, msg) + } + /// Build an `ExternalApi` error. + pub fn external_api(msg: impl Into>) -> Self { + Self::with(AppErrorKind::ExternalApi, msg) + } + /// Build a `Queue` error. + pub fn queue(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Queue, msg) + } + /// Build a `Cache` error. + pub fn cache(msg: impl Into>) -> Self { + Self::with(AppErrorKind::Cache, msg) + } +} diff --git a/src/app_error/core.rs b/src/app_error/core.rs new file mode 100644 index 0000000..b6863a1 --- /dev/null +++ b/src/app_error/core.rs @@ -0,0 +1,130 @@ +use std::borrow::Cow; + +use tracing::error; + +use crate::{Error, RetryAdvice, code::AppCode, kind::AppErrorKind}; + +/// Thin error wrapper: kind + optional message. +/// +/// `Display` prints only the `kind`. The optional `message` is intended for +/// logs and (when appropriate) public JSON payloads. Keep messages concise and +/// free of sensitive data. +#[derive(Debug, Error)] +#[error("{kind}")] +pub struct AppError { + /// Semantic category of the error. + pub kind: AppErrorKind, + /// Optional, public-friendly message. + pub message: Option>, + /// Optional retry advice rendered as `Retry-After`. + pub retry: Option, + /// Optional authentication challenge for `WWW-Authenticate`. + pub www_authenticate: Option +} + +/// Conventional result alias for application code. +/// +/// The alias defaults to [`AppError`] but accepts a custom error type when the +/// context requires a different domain error. +/// +/// # Examples +/// +/// ```rust +/// use std::io::Error; +/// +/// use masterror::AppResult; +/// +/// fn app_logic() -> AppResult { +/// Ok(7) +/// } +/// +/// fn io_logic() -> AppResult<(), Error> { +/// Ok(()) +/// } +/// +/// assert_eq!(app_logic().unwrap(), 7); +/// assert!(io_logic().is_ok()); +/// ``` +pub type AppResult = Result; + +impl AppError { + /// Create a new [`AppError`] with a kind and message. + /// + /// This is equivalent to [`AppError::with`], provided for API symmetry and + /// to keep doctests readable. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind}; + /// let err = AppError::new(AppErrorKind::BadRequest, "invalid payload"); + /// assert!(err.message.is_some()); + /// ``` + pub fn new(kind: AppErrorKind, msg: impl Into>) -> Self { + Self::with(kind, msg) + } + + /// Create an error with the given kind and message. + /// + /// Prefer named helpers (e.g. [`AppError::not_found`]) where it clarifies + /// intent. + pub fn with(kind: AppErrorKind, msg: impl Into>) -> Self { + Self { + kind, + message: Some(msg.into()), + retry: None, + www_authenticate: None + } + } + + /// Create a message-less error with the given kind. + /// + /// Useful when the kind alone conveys sufficient information to the client. + pub fn bare(kind: AppErrorKind) -> Self { + Self { + kind, + message: None, + retry: None, + www_authenticate: None + } + } + + /// Attach retry advice to the error. + /// + /// When mapped to HTTP, this becomes the `Retry-After` header. + #[must_use] + pub fn with_retry_after_secs(mut self, secs: u64) -> Self { + self.retry = Some(RetryAdvice { + after_seconds: secs + }); + self + } + + /// Attach a `WWW-Authenticate` challenge string. + #[must_use] + pub fn with_www_authenticate(mut self, value: impl Into) -> Self { + self.www_authenticate = Some(value.into()); + self + } + + /// Log the error once at the boundary with stable fields. + /// + /// Emits a `tracing::error!` with `kind`, `code` and optional `message`. + /// No internals or sources are leaked. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, AppErrorKind}; + /// let err = AppError::internal("boom"); + /// // In production, call this at the boundary (e.g. HTTP mapping). + /// err.log(); + /// ``` + pub fn log(&self) { + let code = AppCode::from(self.kind); + match &self.message { + Some(m) => error!(kind = ?self.kind, code = %code, message = %m), + None => error!(kind = ?self.kind, code = %code) + } + } +} diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs new file mode 100644 index 0000000..feebc54 --- /dev/null +++ b/src/app_error/tests.rs @@ -0,0 +1,155 @@ +use super::{AppResult, core::AppError}; +use crate::AppErrorKind; + +// --- Helpers ------------------------------------------------------------- + +/// Assert helper: kind matches and message is Some(s). +fn assert_err_with_msg(err: AppError, expected: AppErrorKind, msg: &str) { + assert!( + matches!(err.kind, k if k == expected), + "expected kind {:?}, got {:?}", + expected, + err.kind + ); + assert_eq!(err.message.as_deref(), Some(msg)); +} + +/// Assert helper: kind matches and message is None. +fn assert_err_bare(err: AppError, expected: AppErrorKind) { + assert!( + matches!(err.kind, k if k == expected), + "expected kind {:?}, got {:?}", + expected, + err.kind + ); + assert!(err.message.is_none()); +} + +#[test] +fn constructors_match_kinds() { + assert_err_with_msg( + AppError::not_found("missing"), + AppErrorKind::NotFound, + "missing" + ); + assert_err_with_msg( + AppError::validation("invalid"), + AppErrorKind::Validation, + "invalid" + ); + assert_err_with_msg( + AppError::unauthorized("need token"), + AppErrorKind::Unauthorized, + "need token" + ); + assert_err_with_msg( + AppError::forbidden("no access"), + AppErrorKind::Forbidden, + "no access" + ); + assert_err_with_msg(AppError::conflict("dup"), AppErrorKind::Conflict, "dup"); + assert_err_with_msg( + AppError::bad_request("bad"), + AppErrorKind::BadRequest, + "bad" + ); + assert_err_with_msg( + AppError::rate_limited("slow"), + AppErrorKind::RateLimited, + "slow" + ); + assert_err_with_msg( + AppError::telegram_auth("fail"), + AppErrorKind::TelegramAuth, + "fail" + ); + assert_err_with_msg(AppError::internal("oops"), AppErrorKind::Internal, "oops"); + assert_err_with_msg(AppError::service("down"), AppErrorKind::Service, "down"); + assert_err_with_msg(AppError::config("bad cfg"), AppErrorKind::Config, "bad cfg"); + assert_err_with_msg( + AppError::turnkey("turnkey"), + AppErrorKind::Turnkey, + "turnkey" + ); + assert_err_with_msg( + AppError::timeout("timeout"), + AppErrorKind::Timeout, + "timeout" + ); + assert_err_with_msg(AppError::network("net"), AppErrorKind::Network, "net"); + assert_err_with_msg( + AppError::dependency_unavailable("dep"), + AppErrorKind::DependencyUnavailable, + "dep" + ); + assert_err_with_msg( + AppError::service_unavailable("dep"), + AppErrorKind::DependencyUnavailable, + "dep" + ); + assert_err_with_msg( + AppError::serialization("ser"), + AppErrorKind::Serialization, + "ser" + ); + assert_err_with_msg( + AppError::deserialization("deser"), + AppErrorKind::Deserialization, + "deser" + ); + assert_err_with_msg( + AppError::external_api("external"), + AppErrorKind::ExternalApi, + "external" + ); + assert_err_with_msg(AppError::queue("queue"), AppErrorKind::Queue, "queue"); + assert_err_with_msg(AppError::cache("cache"), AppErrorKind::Cache, "cache"); +} + +#[test] +fn database_accepts_optional_message() { + let with_msg = AppError::database(Some("db down")); + assert_err_with_msg(with_msg, AppErrorKind::Database, "db down"); + + let without = AppError::database(None::<&str>); + assert_err_bare(without, AppErrorKind::Database); +} + +#[test] +fn bare_sets_kind_without_message() { + assert_err_bare( + AppError::bare(AppErrorKind::Internal), + AppErrorKind::Internal + ); +} + +#[test] +fn retry_and_www_authenticate_are_attached() { + let err = AppError::internal("boom") + .with_retry_after_secs(30) + .with_www_authenticate("Bearer"); + assert_eq!(err.retry.unwrap().after_seconds, 30); + assert_eq!(err.www_authenticate.as_deref(), Some("Bearer")); +} + +#[test] +fn log_uses_kind_and_code() { + // Smoke test to ensure the method is callable; tracing output isn't asserted + // here. + let err = AppError::internal("boom"); + err.log(); +} + +#[test] +fn result_alias_is_generic() { + fn app() -> AppResult { + Ok(1) + } + + fn other() -> AppResult { + Ok(2) + } + + assert_eq!(app().unwrap(), 1); + assert_eq!(other().unwrap(), 2); +} diff --git a/src/response.rs b/src/response.rs index 7763f39..9f5c1f3 100644 --- a/src/response.rs +++ b/src/response.rs @@ -59,633 +59,19 @@ //! stable machine-readable code. A temporary [`ErrorResponse::new_legacy`] is //! provided as a deprecated shim. -use std::{ - borrow::Cow, - fmt::{Display, Formatter, Result as FmtResult}, - time::Duration -}; - -use http::StatusCode; -use serde::{Deserialize, Serialize}; -#[cfg(feature = "serde_json")] -use serde_json::{Value as JsonValue, to_value}; -#[cfg(feature = "openapi")] -use utoipa::ToSchema; - -use crate::{ - app_error::{AppError, AppResult}, - code::AppCode -}; - -/// Retry advice intended for API clients. -/// -/// When present, HTTP adapters set the `Retry-After` header with the number of -/// seconds. -#[cfg_attr(feature = "openapi", derive(ToSchema))] -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] -pub struct RetryAdvice { - /// Number of seconds the client should wait before retrying. - pub after_seconds: u64 -} - -/// Public, wire-level error payload for HTTP APIs. -/// -/// This type is serialized to JSON (or another transport format) and forms part -/// of the stable wire contract between services and clients. -#[cfg_attr(feature = "openapi", derive(ToSchema))] -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ErrorResponse { - /// HTTP status code (e.g. 404, 422, 500). - pub status: u16, - /// Stable machine-readable error code (enum). - pub code: AppCode, - /// Human-oriented, non-sensitive message. - pub message: String, - - /// Optional structured details (JSON if `serde_json` is enabled). - #[serde(skip_serializing_if = "Option::is_none")] - #[cfg(feature = "serde_json")] - pub details: Option, - - /// Optional textual details (if `serde_json` is *not* enabled). - #[serde(skip_serializing_if = "Option::is_none")] - #[cfg(not(feature = "serde_json"))] - pub details: Option, - - /// Optional retry advice. If present, integrations set the `Retry-After` - /// header. - #[serde(skip_serializing_if = "Option::is_none")] - pub retry: Option, - - /// Optional authentication challenge. If present, integrations set the - /// `WWW-Authenticate` header. - /// - /// Example value: `Bearer realm="api", error="invalid_token"`. - #[serde(skip_serializing_if = "Option::is_none")] - pub www_authenticate: Option -} - -impl ErrorResponse { - /// Construct a new [`ErrorResponse`] with a status code, a stable - /// [`AppCode`], and a public message. - /// - /// # 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). - #[cfg(not(feature = "serde_json"))] - #[must_use] - pub fn with_details_text(mut self, details: impl Into) -> Self { - self.details = Some(details.into()); - self - } - - /// Attach structured JSON details (available when `serde_json` is enabled). - #[cfg(feature = "serde_json")] - #[must_use] - pub fn with_details_json(mut self, details: JsonValue) -> Self { - self.details = Some(details); - self - } - - /// Serialize and attach structured details from any [`Serialize`] value. - /// - /// # Errors - /// - /// Returns [`AppError`] if serialization fails. - /// - /// # Examples - /// ``` - /// # #[cfg(feature = "serde_json")] - /// # { - /// use masterror::{AppCode, ErrorResponse}; - /// use serde::Serialize; - /// - /// #[derive(Serialize)] - /// struct Extra { - /// reason: String - /// } - /// - /// let payload = Extra { - /// reason: "missing".into() - /// }; - /// let resp = ErrorResponse::new(404, AppCode::NotFound, "no user") - /// .expect("status") - /// .with_details(payload) - /// .expect("details"); - /// assert!(resp.details.is_some()); - /// # } - /// ``` - #[cfg(feature = "serde_json")] - pub fn with_details(self, payload: T) -> AppResult - where - T: Serialize - { - let details = to_value(payload).map_err(|e| AppError::bad_request(e.to_string()))?; - Ok(self.with_details_json(details)) - } - - /// Attach retry advice (number of seconds). - /// - /// See [`with_retry_after_duration`](Self::with_retry_after_duration) for - /// using a [`Duration`]. When present, integrations set the `Retry-After` - /// header automatically. - #[must_use] - pub fn with_retry_after_secs(mut self, secs: u64) -> Self { - self.retry = Some(RetryAdvice { - after_seconds: secs - }); - self - } - - /// Attach retry advice as a [`Duration`]. - /// - /// Equivalent to [`with_retry_after_secs`](Self::with_retry_after_secs). - /// When present, integrations set the `Retry-After` header automatically. - /// - /// # Examples - /// - /// ```rust - /// use std::time::Duration; - /// - /// use masterror::{AppCode, ErrorResponse}; - /// - /// let resp = ErrorResponse::new(503, AppCode::Internal, "retry later") - /// .expect("status") - /// .with_retry_after_duration(Duration::from_secs(60)); - /// assert_eq!(resp.retry.expect("retry").after_seconds, 60); - /// ``` - #[must_use] - pub fn with_retry_after_duration(self, dur: Duration) -> Self { - self.with_retry_after_secs(dur.as_secs()) - } - - /// Attach an authentication challenge string. - /// - /// When present, integrations set the `WWW-Authenticate` header - /// automatically. - #[must_use] - pub fn with_www_authenticate(mut self, value: impl Into) -> Self { - self.www_authenticate = Some(value.into()); - self - } - - /// Convert numeric [`status`](ErrorResponse::status) into [`StatusCode`]. - /// - /// Invalid codes default to `StatusCode::INTERNAL_SERVER_ERROR`. - /// - /// # Examples - /// ``` - /// use http::StatusCode; - /// use masterror::{AppCode, ErrorResponse}; - /// - /// let resp = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); - /// assert_eq!(resp.status_code(), StatusCode::NOT_FOUND); - /// ``` - #[must_use] - pub fn status_code(&self) -> StatusCode { - StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) - } -} - -/// Legacy constructor retained for migration purposes. -/// -/// Deprecated: prefer [`ErrorResponse::new`] with an [`AppCode`] argument. -#[deprecated(note = "Use new(status, code, message) instead")] -impl ErrorResponse { - /// Construct an error response with only `(status, message)`. - /// - /// This defaults the code to [`AppCode::Internal`]. Kept temporarily to - /// ease migration from versions prior to 0.3.0. - #[must_use] - pub fn new_legacy(status: u16, message: impl Into) -> Self { - 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 - }) - } -} - -impl Display for ErrorResponse { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - // Concise string form, safe for logs and debugging. - write!(f, "{} {:?}: {}", self.status, self.code, self.message) - } -} - -impl From<&AppError> for ErrorResponse { - fn from(err: &AppError) -> Self { - let status = err.kind.http_status(); - let code = AppCode::from(err.kind); - - let message = err - .message - .clone() - .unwrap_or(Cow::Borrowed("An error occurred")) - .into_owned(); - - Self { - status, - code, - message, - details: None, - retry: err.retry, - www_authenticate: err.www_authenticate.clone() - } - } -} +mod core; +mod details; +mod legacy; +mod mapping; +mod metadata; #[cfg(feature = "axum")] -mod axum_impl { - //! Axum integration: implements [`IntoResponse`] for [`ErrorResponse`]. - //! - //! Behavior: - //! - Serializes the response as JSON with the given status. - //! - Adds `Retry-After` if [`ErrorResponse::retry`] is present. - //! - Adds `WWW-Authenticate` if [`ErrorResponse::www_authenticate`] is - //! present. - - use axum::{ - Json, - http::{ - HeaderValue, - header::{RETRY_AFTER, WWW_AUTHENTICATE} - }, - response::{IntoResponse, Response} - }; - - use super::ErrorResponse; - use crate::AppError; - - impl IntoResponse for ErrorResponse { - fn into_response(self) -> Response { - let status = self.status_code(); - - // Serialize JSON body first (borrow self for payload). - let mut response = (status, Json(&self)).into_response(); - - if let Some(retry) = self.retry - && let Ok(hv) = HeaderValue::from_str(&retry.after_seconds.to_string()) - { - response.headers_mut().insert(RETRY_AFTER, hv); - } - if let Some(ch) = &self.www_authenticate - && let Ok(hv) = HeaderValue::from_str(ch) - { - response.headers_mut().insert(WWW_AUTHENTICATE, hv); - } - - response - } - } - - /// Convert `AppError` into the stable wire model and reuse its - /// `IntoResponse`. - impl IntoResponse for AppError { - fn into_response(self) -> Response { - // Use the canonical mapping defined in `From<&AppError> for ErrorResponse` - let wire: ErrorResponse = (&self).into(); - wire.into_response() - } - } -} +mod axum_impl; #[cfg(feature = "actix")] -mod actix_impl { - //! Actix integration: implements [`Responder`] for [`ErrorResponse`]. - //! - //! Behavior: - //! - Serializes the response as JSON with the given status. - //! - Adds `Retry-After` if [`ErrorResponse::retry`] is present. - //! - Adds `WWW-Authenticate` if [`ErrorResponse::www_authenticate`] is - //! present. - - use actix_web::{ - HttpRequest, HttpResponse, Responder, - body::BoxBody, - http::header::{RETRY_AFTER, WWW_AUTHENTICATE} - }; +mod actix_impl; - use super::ErrorResponse; - - impl Responder for ErrorResponse { - type Body = BoxBody; - - fn respond_to(self, _req: &HttpRequest) -> HttpResponse { - let mut builder = HttpResponse::build( - actix_web::http::StatusCode::from_u16(self.status) - .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR) - ); - if let Some(retry) = self.retry { - builder.insert_header((RETRY_AFTER, retry.after_seconds.to_string())); - } - if let Some(ref ch) = self.www_authenticate { - // Pass &str, not &String, to satisfy TryIntoHeaderPair - builder.insert_header((WWW_AUTHENTICATE, ch.as_str())); - } - builder.json(self) - } - } -} +pub use core::{ErrorResponse, RetryAdvice}; #[cfg(test)] -mod tests { - use super::*; - use crate::AppErrorKind; - - // --- Basic constructors and fields -------------------------------------- - - #[test] - fn new_sets_status_code_and_message() { - 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); - assert_eq!(e.retry.unwrap().after_seconds, 15); - assert_eq!(e.www_authenticate.as_deref(), Some(r#"Bearer realm="api""#)); - } - - #[test] - fn with_retry_after_duration_attaches_advice() { - use std::time::Duration; - - let e = ErrorResponse::new(429, AppCode::RateLimited, "slow down") - .expect("status") - .with_retry_after_duration(Duration::from_secs(42)); - assert_eq!(e.retry.unwrap().after_seconds, 42); - } - - #[test] - fn status_code_maps_invalid_to_internal_server_error() { - use http::StatusCode; - - let valid = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); - assert_eq!(valid.status_code(), StatusCode::NOT_FOUND); - - let invalid = ErrorResponse { - status: 1000, - code: AppCode::Internal, - message: "oops".into(), - details: None, - retry: None, - www_authenticate: None - }; - assert_eq!(invalid.status_code(), StatusCode::INTERNAL_SERVER_ERROR); - } - - // --- Details: JSON vs text ---------------------------------------------- - - #[cfg(feature = "serde_json")] - #[test] - 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()); - assert_eq!(e.details.unwrap(), payload); - } - - #[cfg(feature = "serde_json")] - #[test] - fn with_details_serializes_custom_struct() { - use serde::Serialize; - use serde_json::json; - - #[derive(Serialize)] - struct Extra { - value: i32 - } - - let resp = ErrorResponse::new(400, AppCode::BadRequest, "bad") - .expect("status") - .with_details(Extra { - value: 7 - }) - .expect("details"); - - assert_eq!(resp.details.unwrap(), json!({"value": 7})); - } - - #[cfg(feature = "serde_json")] - #[test] - fn with_details_propagates_serialization_errors() { - use serde::{Serialize, Serializer}; - - struct Failing; - - impl Serialize for Failing { - fn serialize(&self, _: S) -> Result - where - S: Serializer - { - Err(serde::ser::Error::custom("nope")) - } - } - - let err = ErrorResponse::new(400, AppCode::BadRequest, "bad") - .expect("status") - .with_details(Failing) - .expect_err("serialization should fail"); - assert!(matches!(err.kind, AppErrorKind::BadRequest)); - } - - #[cfg(not(feature = "serde_json"))] - #[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")); - } - - // --- From<&AppError> mapping -------------------------------------------- - - #[test] - fn from_app_error_preserves_status_and_sets_code() { - let app = crate::AppError::new(AppErrorKind::NotFound, "user"); - let e: ErrorResponse = (&app).into(); - assert_eq!(e.status, 404); - assert!(matches!(e.code, AppCode::NotFound)); - assert_eq!(e.message, "user"); - assert!(e.retry.is_none()); - } - - #[test] - fn from_app_error_uses_default_message_when_none() { - let app = crate::AppError::bare(AppErrorKind::Internal); - let e: ErrorResponse = (&app).into(); - assert_eq!(e.status, 500); - assert!(matches!(e.code, AppCode::Internal)); - assert_eq!(e.message, "An error occurred"); - } - - // --- Display formatting -------------------------------------------------- - - #[test] - fn display_is_concise_and_does_not_leak_details() { - let e = ErrorResponse::new(400, AppCode::BadRequest, "bad").expect("status"); - let s = format!("{}", e); - assert!(s.contains("400"), "status should be present"); - assert!( - s.to_lowercase().contains("badrequest") - || s.contains("BAD_REQUEST") - || s.contains("BadRequest"), - "code should be present in some form" - ); - assert!(s.contains("bad"), "message should be present"); - } - - // --- Legacy constructor (migration shim) -------------------------------- - - #[allow(deprecated)] - #[test] - fn new_legacy_defaults_to_internal_code() { - let e = ErrorResponse::new_legacy(500, "boom"); - assert_eq!(e.status, 500); - assert!(matches!(e.code, AppCode::Internal)); - assert_eq!(e.message, "boom"); - } - - // --- Axum adapter: headers and status ----------------------------------- - - #[cfg(feature = "axum")] - #[test] - fn axum_into_response_sets_headers_and_status() { - use axum::{ - http::header::{RETRY_AFTER, WWW_AUTHENTICATE}, - response::IntoResponse - }; - - 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(); - - assert_eq!(resp.status(), 401); - let headers = resp.headers(); - assert_eq!(headers.get(RETRY_AFTER).unwrap(), "7"); - assert_eq!( - headers.get(WWW_AUTHENTICATE).unwrap(), - r#"Bearer realm="api", error="invalid_token""# - ); - } - - // --- Actix adapter: headers and status ---------------------------------- - - #[cfg(feature = "actix")] - #[test] - fn actix_responder_sets_headers_and_status() { - use actix_web::{ - Responder, - http::{ - StatusCode, - header::{RETRY_AFTER, WWW_AUTHENTICATE} - }, - test::TestRequest - }; - - // 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"); - - // Build a minimal HttpRequest for Responder::respond_to - let req = TestRequest::default().to_http_request(); - - // `respond_to` builds HttpResponse synchronously; we can inspect it. - let http = resp.respond_to(&req); - assert_eq!(http.status(), StatusCode::TOO_MANY_REQUESTS); - - let headers = http.headers(); - assert_eq!(headers.get(RETRY_AFTER).unwrap(), "42"); - assert_eq!(headers.get(WWW_AUTHENTICATE).unwrap(), "Bearer"); - } - - #[cfg(feature = "actix")] - #[test] - fn actix_responder_no_optional_headers_by_default() { - use actix_web::{ - Responder, - http::header::{RETRY_AFTER, WWW_AUTHENTICATE}, - test::TestRequest - }; - - let resp = ErrorResponse::new(500, AppCode::Internal, "boom").expect("status"); - let req = TestRequest::default().to_http_request(); - let http = resp.respond_to(&req); - - let headers = http.headers(); - assert!(headers.get(RETRY_AFTER).is_none()); - assert!(headers.get(WWW_AUTHENTICATE).is_none()); - } - - // --- Serde snapshot-ish check ------------------------------------------- - - #[cfg(feature = "serde_json")] - #[test] - fn serialized_json_contains_core_fields() { - 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")); - assert!(s.contains("\"code\":\"NOT_FOUND\"")); - assert!(s.contains("\"message\":\"nope\"")); - // Retry advice is serialized as nested object - assert!(s.contains("\"retry\"")); - assert!(s.contains("\"after_seconds\":1")); - } - - #[cfg(feature = "axum")] - #[test] - fn app_error_into_response_maps_status() { - use axum::response::IntoResponse; - - use crate::AppErrorKind; - - let app = crate::AppError::new(AppErrorKind::Unauthorized, "no token"); - let resp = app.into_response(); - assert_eq!(resp.status(), 401); - } -} +mod tests; diff --git a/src/response/actix_impl.rs b/src/response/actix_impl.rs new file mode 100644 index 0000000..3359f63 --- /dev/null +++ b/src/response/actix_impl.rs @@ -0,0 +1,33 @@ +//! Actix integration: implements [`Responder`] for [`ErrorResponse`]. +//! +//! Behavior: +//! - Serializes the response as JSON with the given status. +//! - Adds `Retry-After` if [`ErrorResponse::retry`] is present. +//! - Adds `WWW-Authenticate` if [`ErrorResponse::www_authenticate`] is present. + +use actix_web::{ + HttpRequest, HttpResponse, Responder, + body::BoxBody, + http::header::{RETRY_AFTER, WWW_AUTHENTICATE} +}; + +use super::ErrorResponse; + +impl Responder for ErrorResponse { + type Body = BoxBody; + + fn respond_to(self, _req: &HttpRequest) -> HttpResponse { + let mut builder = HttpResponse::build( + actix_web::http::StatusCode::from_u16(self.status) + .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR) + ); + if let Some(retry) = self.retry { + builder.insert_header((RETRY_AFTER, retry.after_seconds.to_string())); + } + if let Some(ref ch) = self.www_authenticate { + // Pass &str, not &String, to satisfy TryIntoHeaderPair + builder.insert_header((WWW_AUTHENTICATE, ch.as_str())); + } + builder.json(self) + } +} diff --git a/src/response/axum_impl.rs b/src/response/axum_impl.rs new file mode 100644 index 0000000..2dacae7 --- /dev/null +++ b/src/response/axum_impl.rs @@ -0,0 +1,49 @@ +//! Axum integration: implements [`IntoResponse`] for [`ErrorResponse`]. +//! +//! Behavior: +//! - Serializes the response as JSON with the given status. +//! - Adds `Retry-After` if [`ErrorResponse::retry`] is present. +//! - Adds `WWW-Authenticate` if [`ErrorResponse::www_authenticate`] is present. + +use axum::{ + Json, + http::{ + HeaderValue, + header::{RETRY_AFTER, WWW_AUTHENTICATE} + }, + response::{IntoResponse, Response} +}; + +use super::ErrorResponse; +use crate::AppError; + +impl IntoResponse for ErrorResponse { + fn into_response(self) -> Response { + let status = self.status_code(); + + // Serialize JSON body first (borrow self for payload). + let mut response = (status, Json(&self)).into_response(); + + if let Some(retry) = self.retry + && let Ok(hv) = HeaderValue::from_str(&retry.after_seconds.to_string()) + { + response.headers_mut().insert(RETRY_AFTER, hv); + } + if let Some(ch) = &self.www_authenticate + && let Ok(hv) = HeaderValue::from_str(ch) + { + response.headers_mut().insert(WWW_AUTHENTICATE, hv); + } + + response + } +} + +/// Convert `AppError` into the stable wire model and reuse its `IntoResponse`. +impl IntoResponse for AppError { + fn into_response(self) -> Response { + // Use the canonical mapping defined in `From<&AppError> for ErrorResponse` + let wire: ErrorResponse = (&self).into(); + wire.into_response() + } +} diff --git a/src/response/core.rs b/src/response/core.rs new file mode 100644 index 0000000..f6bb5fe --- /dev/null +++ b/src/response/core.rs @@ -0,0 +1,94 @@ +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::{AppCode, AppError, AppResult}; + +/// Retry advice intended for API clients. +/// +/// When present, HTTP adapters set the `Retry-After` header with the number of +/// seconds. +#[cfg_attr(feature = "openapi", derive(ToSchema))] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +pub struct RetryAdvice { + /// Number of seconds the client should wait before retrying. + pub after_seconds: u64 +} + +/// Public, wire-level error payload for HTTP APIs. +/// +/// This type is serialized to JSON (or another transport format) and forms part +/// of the stable wire contract between services and clients. +#[cfg_attr(feature = "openapi", derive(ToSchema))] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ErrorResponse { + /// HTTP status code (e.g. 404, 422, 500). + pub status: u16, + /// Stable machine-readable error code (enum). + pub code: AppCode, + /// Human-oriented, non-sensitive message. + pub message: String, + + /// Optional structured details (JSON if `serde_json` is enabled). + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg(feature = "serde_json")] + pub details: Option, + + /// Optional textual details (if `serde_json` is *not* enabled). + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg(not(feature = "serde_json"))] + pub details: Option, + + /// Optional retry advice. If present, integrations set the `Retry-After` + /// header. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry: Option, + + /// Optional authentication challenge. If present, integrations set the + /// `WWW-Authenticate` header. + /// + /// Example value: `Bearer realm="api", error="invalid_token"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub www_authenticate: Option +} + +impl ErrorResponse { + /// Construct a new [`ErrorResponse`] with a status code, a stable + /// [`AppCode`], and a public message. + /// + /// # 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 + }) + } + + /// Convert numeric [`status`](ErrorResponse::status) into [`StatusCode`]. + /// + /// Invalid codes default to `StatusCode::INTERNAL_SERVER_ERROR`. + /// + /// # Examples + /// ``` + /// use http::StatusCode; + /// use masterror::{AppCode, ErrorResponse}; + /// + /// let resp = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); + /// assert_eq!(resp.status_code(), StatusCode::NOT_FOUND); + /// ``` + #[must_use] + pub fn status_code(&self) -> StatusCode { + StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + } +} diff --git a/src/response/details.rs b/src/response/details.rs new file mode 100644 index 0000000..ceca799 --- /dev/null +++ b/src/response/details.rs @@ -0,0 +1,64 @@ +#[cfg(feature = "serde_json")] +use serde::Serialize; +#[cfg(feature = "serde_json")] +use serde_json::{Value as JsonValue, to_value}; + +use super::core::ErrorResponse; +#[cfg(feature = "serde_json")] +use crate::{AppError, AppResult}; + +#[cfg(not(feature = "serde_json"))] +impl ErrorResponse { + /// Attach plain-text details (available when `serde_json` is disabled). + #[must_use] + pub fn with_details_text(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } +} + +#[cfg(feature = "serde_json")] +impl ErrorResponse { + /// Attach structured JSON details (available when `serde_json` is enabled). + #[must_use] + pub fn with_details_json(mut self, details: JsonValue) -> Self { + self.details = Some(details); + self + } + + /// Serialize and attach structured details from any [`Serialize`] value. + /// + /// # Errors + /// + /// Returns [`AppError`] if serialization fails. + /// + /// # Examples + /// ``` + /// # #[cfg(feature = "serde_json")] + /// # { + /// use masterror::{AppCode, ErrorResponse}; + /// use serde::Serialize; + /// + /// #[derive(Serialize)] + /// struct Extra { + /// reason: String + /// } + /// + /// let payload = Extra { + /// reason: "missing".into() + /// }; + /// let resp = ErrorResponse::new(404, AppCode::NotFound, "no user") + /// .expect("status") + /// .with_details(payload) + /// .expect("details"); + /// assert!(resp.details.is_some()); + /// # } + /// ``` + pub fn with_details(self, payload: T) -> AppResult + where + T: Serialize + { + let details = to_value(payload).map_err(|e| AppError::bad_request(e.to_string()))?; + Ok(self.with_details_json(details)) + } +} diff --git a/src/response/legacy.rs b/src/response/legacy.rs new file mode 100644 index 0000000..98c3383 --- /dev/null +++ b/src/response/legacy.rs @@ -0,0 +1,25 @@ +use super::core::ErrorResponse; +use crate::AppCode; + +/// Legacy constructor retained for migration purposes. +/// +/// Deprecated: prefer [`ErrorResponse::new`] with an [`AppCode`] argument. +#[deprecated(note = "Use new(status, code, message) instead")] +impl ErrorResponse { + /// Construct an error response with only `(status, message)`. + /// + /// This defaults the code to [`AppCode::Internal`]. Kept temporarily to + /// ease migration from versions prior to 0.3.0. + #[must_use] + pub fn new_legacy(status: u16, message: impl Into) -> Self { + 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 + }) + } +} diff --git a/src/response/mapping.rs b/src/response/mapping.rs new file mode 100644 index 0000000..cb9918d --- /dev/null +++ b/src/response/mapping.rs @@ -0,0 +1,36 @@ +use std::{ + borrow::Cow, + fmt::{Display, Formatter, Result as FmtResult} +}; + +use super::core::ErrorResponse; +use crate::{AppCode, AppError}; + +impl Display for ErrorResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + // Concise string form, safe for logs and debugging. + write!(f, "{} {:?}: {}", self.status, self.code, self.message) + } +} + +impl From<&AppError> for ErrorResponse { + fn from(err: &AppError) -> Self { + let status = err.kind.http_status(); + let code = AppCode::from(err.kind); + + let message = err + .message + .clone() + .unwrap_or(Cow::Borrowed("An error occurred")) + .into_owned(); + + Self { + status, + code, + message, + details: None, + retry: err.retry, + www_authenticate: err.www_authenticate.clone() + } + } +} diff --git a/src/response/metadata.rs b/src/response/metadata.rs new file mode 100644 index 0000000..56b6b74 --- /dev/null +++ b/src/response/metadata.rs @@ -0,0 +1,50 @@ +use std::time::Duration; + +use super::core::{ErrorResponse, RetryAdvice}; + +impl ErrorResponse { + /// Attach retry advice (number of seconds). + /// + /// See [`with_retry_after_duration`](Self::with_retry_after_duration) for + /// using a [`Duration`]. When present, integrations set the `Retry-After` + /// header automatically. + #[must_use] + pub fn with_retry_after_secs(mut self, secs: u64) -> Self { + self.retry = Some(RetryAdvice { + after_seconds: secs + }); + self + } + + /// Attach retry advice as a [`Duration`]. + /// + /// Equivalent to [`with_retry_after_secs`](Self::with_retry_after_secs). + /// When present, integrations set the `Retry-After` header automatically. + /// + /// # Examples + /// + /// ```rust + /// use std::time::Duration; + /// + /// use masterror::{AppCode, ErrorResponse}; + /// + /// let resp = ErrorResponse::new(503, AppCode::Internal, "retry later") + /// .expect("status") + /// .with_retry_after_duration(Duration::from_secs(60)); + /// assert_eq!(resp.retry.expect("retry").after_seconds, 60); + /// ``` + #[must_use] + pub fn with_retry_after_duration(self, dur: Duration) -> Self { + self.with_retry_after_secs(dur.as_secs()) + } + + /// Attach an authentication challenge string. + /// + /// When present, integrations set the `WWW-Authenticate` header + /// automatically. + #[must_use] + pub fn with_www_authenticate(mut self, value: impl Into) -> Self { + self.www_authenticate = Some(value.into()); + self + } +} diff --git a/src/response/tests.rs b/src/response/tests.rs new file mode 100644 index 0000000..fad84be --- /dev/null +++ b/src/response/tests.rs @@ -0,0 +1,278 @@ +use super::ErrorResponse; +use crate::{AppCode, AppError, AppErrorKind}; + +// --- Basic constructors and fields -------------------------------------- + +#[test] +fn new_sets_status_code_and_message() { + 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); + assert_eq!(e.retry.unwrap().after_seconds, 15); + assert_eq!(e.www_authenticate.as_deref(), Some(r#"Bearer realm="api""#)); +} + +#[test] +fn with_retry_after_duration_attaches_advice() { + use std::time::Duration; + + let e = ErrorResponse::new(429, AppCode::RateLimited, "slow down") + .expect("status") + .with_retry_after_duration(Duration::from_secs(42)); + assert_eq!(e.retry.unwrap().after_seconds, 42); +} + +#[test] +fn status_code_maps_invalid_to_internal_server_error() { + use http::StatusCode; + + let valid = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); + assert_eq!(valid.status_code(), StatusCode::NOT_FOUND); + + let invalid = ErrorResponse { + status: 1000, + code: AppCode::Internal, + message: "oops".into(), + details: None, + retry: None, + www_authenticate: None + }; + assert_eq!(invalid.status_code(), StatusCode::INTERNAL_SERVER_ERROR); +} + +// --- Details: JSON vs text ---------------------------------------------- + +#[cfg(feature = "serde_json")] +#[test] +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()); + assert_eq!(e.details.unwrap(), payload); +} + +#[cfg(feature = "serde_json")] +#[test] +fn with_details_serializes_custom_struct() { + use serde::Serialize; + use serde_json::json; + + #[derive(Serialize)] + struct Extra { + value: i32 + } + + let resp = ErrorResponse::new(400, AppCode::BadRequest, "bad") + .expect("status") + .with_details(Extra { + value: 7 + }) + .expect("details"); + + assert_eq!(resp.details.unwrap(), json!({"value": 7})); +} + +#[cfg(feature = "serde_json")] +#[test] +fn with_details_propagates_serialization_errors() { + use serde::{Serialize, Serializer}; + + struct Failing; + + impl Serialize for Failing { + fn serialize(&self, _: S) -> Result + where + S: Serializer + { + Err(serde::ser::Error::custom("nope")) + } + } + + let err = ErrorResponse::new(400, AppCode::BadRequest, "bad") + .expect("status") + .with_details(Failing) + .expect_err("serialization should fail"); + assert!(matches!(err.kind, AppErrorKind::BadRequest)); +} + +#[cfg(not(feature = "serde_json"))] +#[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")); +} + +// --- From<&AppError> mapping -------------------------------------------- + +#[test] +fn from_app_error_preserves_status_and_sets_code() { + let app = AppError::new(AppErrorKind::NotFound, "user"); + let e: ErrorResponse = (&app).into(); + assert_eq!(e.status, 404); + assert!(matches!(e.code, AppCode::NotFound)); + assert_eq!(e.message, "user"); + assert!(e.retry.is_none()); +} + +#[test] +fn from_app_error_uses_default_message_when_none() { + let app = AppError::bare(AppErrorKind::Internal); + let e: ErrorResponse = (&app).into(); + assert_eq!(e.status, 500); + assert!(matches!(e.code, AppCode::Internal)); + assert_eq!(e.message, "An error occurred"); +} + +// --- Display formatting -------------------------------------------------- + +#[test] +fn display_is_concise_and_does_not_leak_details() { + let e = ErrorResponse::new(400, AppCode::BadRequest, "bad").expect("status"); + let s = format!("{}", e); + assert!(s.contains("400"), "status should be present"); + assert!( + s.to_lowercase().contains("badrequest") + || s.contains("BAD_REQUEST") + || s.contains("BadRequest"), + "code should be present in some form" + ); + assert!(s.contains("bad"), "message should be present"); +} + +// --- Legacy constructor (migration shim) -------------------------------- + +#[allow(deprecated)] +#[test] +fn new_legacy_defaults_to_internal_code() { + let e = ErrorResponse::new_legacy(500, "boom"); + assert_eq!(e.status, 500); + assert!(matches!(e.code, AppCode::Internal)); + assert_eq!(e.message, "boom"); +} + +// --- Axum adapter: headers and status ----------------------------------- + +#[cfg(feature = "axum")] +#[test] +fn axum_into_response_sets_headers_and_status() { + use axum::{ + http::header::{RETRY_AFTER, WWW_AUTHENTICATE}, + response::IntoResponse + }; + + 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(); + + assert_eq!(resp.status(), 401); + let headers = resp.headers(); + assert_eq!(headers.get(RETRY_AFTER).unwrap(), "7"); + assert_eq!( + headers.get(WWW_AUTHENTICATE).unwrap(), + r#"Bearer realm="api", error="invalid_token""# + ); +} + +// --- Actix adapter: headers and status ---------------------------------- + +#[cfg(feature = "actix")] +#[test] +fn actix_responder_sets_headers_and_status() { + use actix_web::{ + Responder, + http::{ + StatusCode, + header::{RETRY_AFTER, WWW_AUTHENTICATE} + }, + test::TestRequest + }; + + // 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"); + + // Build a minimal HttpRequest for Responder::respond_to + let req = TestRequest::default().to_http_request(); + + // `respond_to` builds HttpResponse synchronously; we can inspect it. + let http = resp.respond_to(&req); + assert_eq!(http.status(), StatusCode::TOO_MANY_REQUESTS); + + let headers = http.headers(); + assert_eq!(headers.get(RETRY_AFTER).unwrap(), "42"); + assert_eq!(headers.get(WWW_AUTHENTICATE).unwrap(), "Bearer"); +} + +#[cfg(feature = "actix")] +#[test] +fn actix_responder_no_optional_headers_by_default() { + use actix_web::{ + Responder, + http::header::{RETRY_AFTER, WWW_AUTHENTICATE}, + test::TestRequest + }; + + let resp = ErrorResponse::new(500, AppCode::Internal, "boom").expect("status"); + let req = TestRequest::default().to_http_request(); + let http = resp.respond_to(&req); + + let headers = http.headers(); + assert!(headers.get(RETRY_AFTER).is_none()); + assert!(headers.get(WWW_AUTHENTICATE).is_none()); +} + +// --- Serde snapshot-ish check ------------------------------------------- + +#[cfg(feature = "serde_json")] +#[test] +fn serialized_json_contains_core_fields() { + 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")); + assert!(s.contains("\"code\":\"NOT_FOUND\"")); + assert!(s.contains("\"message\":\"nope\"")); + // Retry advice is serialized as nested object + assert!(s.contains("\"retry\"")); + assert!(s.contains("\"after_seconds\":1")); +} + +#[cfg(feature = "axum")] +#[test] +fn app_error_into_response_maps_status() { + use axum::response::IntoResponse; + + let app = AppError::new(AppErrorKind::Unauthorized, "no token"); + let resp = app.into_response(); + assert_eq!(resp.status(), 401); +}