masterror/response/
core.rs

1use http::StatusCode;
2use serde::{Deserialize, Serialize};
3#[cfg(feature = "serde_json")]
4use serde_json::Value as JsonValue;
5#[cfg(feature = "openapi")]
6use utoipa::ToSchema;
7
8use crate::{AppCode, AppError, AppResult};
9
10/// Retry advice intended for API clients.
11///
12/// When present, HTTP adapters set the `Retry-After` header with the number of
13/// seconds.
14#[cfg_attr(feature = "openapi", derive(ToSchema))]
15#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
16pub struct RetryAdvice {
17    /// Number of seconds the client should wait before retrying.
18    pub after_seconds: u64
19}
20
21/// Public, wire-level error payload for HTTP APIs.
22///
23/// This type is serialized to JSON (or another transport format) and forms part
24/// of the stable wire contract between services and clients.
25#[cfg_attr(feature = "openapi", derive(ToSchema))]
26#[derive(Debug, Serialize, Deserialize, Clone)]
27pub struct ErrorResponse {
28    /// HTTP status code (e.g. 404, 422, 500).
29    pub status:  u16,
30    /// Stable machine-readable error code.
31    pub code:    AppCode,
32    /// Human-oriented, non-sensitive message.
33    pub message: String,
34
35    /// Optional structured details (JSON if `serde_json` is enabled).
36    #[serde(skip_serializing_if = "Option::is_none")]
37    #[cfg(feature = "serde_json")]
38    pub details: Option<JsonValue>,
39
40    /// Optional textual details (if `serde_json` is *not* enabled).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    #[cfg(not(feature = "serde_json"))]
43    pub details: Option<String>,
44
45    /// Optional retry advice. If present, integrations set the `Retry-After`
46    /// header.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub retry: Option<RetryAdvice>,
49
50    /// Optional authentication challenge. If present, integrations set the
51    /// `WWW-Authenticate` header.
52    ///
53    /// Example value: `Bearer realm="api", error="invalid_token"`.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub www_authenticate: Option<String>
56}
57
58impl ErrorResponse {
59    /// Construct a new [`ErrorResponse`] with a status code, a stable
60    /// [`AppCode`], and a public message.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`AppError`] if `status` is not a valid HTTP status code.
65    #[allow(clippy::result_large_err)]
66    pub fn new(status: u16, code: AppCode, message: impl Into<String>) -> AppResult<Self> {
67        StatusCode::from_u16(status)
68            .map_err(|_| AppError::bad_request(format!("invalid HTTP status: {status}")))?;
69        Ok(Self {
70            status,
71            code,
72            message: message.into(),
73            details: None,
74            retry: None,
75            www_authenticate: None
76        })
77    }
78
79    /// Convert numeric [`status`](ErrorResponse::status) into [`StatusCode`].
80    ///
81    /// Invalid codes default to `StatusCode::INTERNAL_SERVER_ERROR`.
82    ///
83    /// # Examples
84    /// ```
85    /// use http::StatusCode;
86    /// use masterror::{AppCode, ErrorResponse};
87    ///
88    /// let resp = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status");
89    /// assert_eq!(resp.status_code(), StatusCode::NOT_FOUND);
90    /// ```
91    #[must_use]
92    pub fn status_code(&self) -> StatusCode {
93        StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
94    }
95
96    /// Formatter exposing internals for diagnostic logs.
97    #[must_use]
98    pub fn internal(&self) -> crate::response::internal::ErrorResponseFormatter<'_> {
99        crate::response::internal::ErrorResponseFormatter::new(self)
100    }
101}
102use alloc::{format, string::String};