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};