Skip to main content

doxa_docs/
problem.rs

1//! Default error response body for `#[derive(ApiError)]`.
2//!
3//! [`ApiErrorBody`] is the envelope shape every error response uses.
4//! The type parameter `E` carries the typed error payload — when the
5//! macro generates `IntoResponse`, it moves the error enum directly
6//! into the envelope as `ApiErrorBody<MyError>`, giving the OpenAPI
7//! spec a fully typed `error` field.
8//!
9//! The envelope holds:
10//!
11//! - **`message`** — human-readable text from the source error's
12//!   [`std::fmt::Display`] impl (typically provided by `thiserror::Error`'s
13//!   `#[error("...")]` template).
14//! - **`error`** — the typed, machine-readable serialization of the error
15//!   variant via [`serde::Serialize`]. Shape matches the enum's serde
16//!   representation (externally tagged by default).
17//! - **`status`** — the HTTP status mirrored into the body for clients that
18//!   don't have easy access to the response status line.
19//! - **`code`** — application-level error code string for client-side
20//!   discrimination (e.g. `"validation_error"`, `"not_found"`).
21//!
22//! The default type parameter `serde_json::Value` is used by layer
23//! contributions and backward-compatible call sites that don't have a
24//! concrete error type.
25//!
26//! [`ProblemDetails`] is still exported for projects that prefer the
27//! RFC 7807 envelope, but the macro no longer uses it by default.
28
29use serde::{Deserialize, Serialize};
30use utoipa::ToSchema;
31
32/// Envelope for HTTP error responses produced by `#[derive(ApiError)]`.
33///
34/// The type parameter `E` determines the shape of the `error` field.
35/// When generated by the macro, `E` is the concrete error enum — giving
36/// frontends a fully typed payload. The default (`serde_json::Value`)
37/// is used by layer contributions and hand-rolled error responses where
38/// the concrete type is not known.
39///
40/// # Example
41///
42/// ```
43/// use doxa::ApiErrorBody;
44///
45/// let body: ApiErrorBody<()> =
46///     ApiErrorBody::new(404, "not_found", "widget 7 does not exist", ());
47/// let json = serde_json::to_value(&body).unwrap();
48/// assert_eq!(json["status"], 404);
49/// assert_eq!(json["code"], "not_found");
50/// assert_eq!(json["message"], "widget 7 does not exist");
51/// ```
52#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
53#[serde(bound(
54    serialize = "E: serde::Serialize",
55    deserialize = "E: serde::de::DeserializeOwned"
56))]
57pub struct ApiErrorBody<E = serde_json::Value> {
58    /// Human-readable message from the source error's `Display` impl.
59    pub message: String,
60    /// HTTP status code, mirrored from the response status line.
61    pub status: u16,
62    /// Application-level error code suitable for client-side
63    /// discrimination (e.g. `"not_found"`, `"validation_error"`).
64    pub code: String,
65    /// Typed, machine-readable error data. When `E` is a concrete error
66    /// enum, this field carries the enum's serde serialization. When `E`
67    /// is the default `serde_json::Value`, it accepts any JSON value.
68    pub error: E,
69}
70
71impl<E: Serialize> ApiErrorBody<E> {
72    /// Construct an [`ApiErrorBody`] from its raw fields.
73    ///
74    /// Most callers should let `#[derive(ApiError)]` build this for
75    /// them; this constructor exists for hand-rolled error responses,
76    /// layer contributions, and tests.
77    pub fn new(status: u16, code: impl Into<String>, message: impl Into<String>, error: E) -> Self {
78        Self {
79            message: message.into(),
80            status,
81            code: code.into(),
82            error,
83        }
84    }
85}
86
87/// RFC 7807 problem details body.
88///
89/// Retained for projects that prefer the standard RFC 7807 envelope.
90/// `#[derive(ApiError)]` no longer emits this by default — see
91/// [`ApiErrorBody`] for the new default. Both types coexist; choose
92/// whichever fits your client conventions.
93#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
94pub struct ProblemDetails {
95    /// A URI reference identifying the problem type. Defaults to
96    /// `"about:blank"` per the RFC.
97    #[serde(default = "default_type", rename = "type")]
98    pub type_: String,
99    /// Short human-readable summary of the problem type.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub title: Option<String>,
102    /// HTTP status code generated for this occurrence.
103    pub status: u16,
104    /// Human-readable explanation specific to this occurrence.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub detail: Option<String>,
107    /// URI reference identifying the specific occurrence.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub instance: Option<String>,
110    /// Application-level error code suitable for client discrimination.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub code: Option<String>,
113}
114
115fn default_type() -> String {
116    "about:blank".to_string()
117}
118
119impl ProblemDetails {
120    /// Construct with the required `status` field.
121    pub fn new(status: u16) -> Self {
122        Self {
123            type_: default_type(),
124            title: None,
125            status,
126            detail: None,
127            instance: None,
128            code: None,
129        }
130    }
131
132    /// Set the title.
133    pub fn with_title(mut self, title: impl Into<String>) -> Self {
134        self.title = Some(title.into());
135        self
136    }
137    /// Set the detail message.
138    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
139        self.detail = Some(detail.into());
140        self
141    }
142    /// Set the application-level error code.
143    pub fn with_code(mut self, code: impl Into<String>) -> Self {
144        self.code = Some(code.into());
145        self
146    }
147    /// Set the instance URI.
148    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
149        self.instance = Some(instance.into());
150        self
151    }
152    /// Set the problem type URI.
153    pub fn with_type(mut self, type_: impl Into<String>) -> Self {
154        self.type_ = type_.into();
155        self
156    }
157}
158
159// No tests in this module — `ApiErrorBody` and `ProblemDetails` are
160// straight serde-derived structs with no logic of our own. Their
161// behavior is verified end-to-end through the macro-generated
162// `IntoResponse` impls in `doxa-macros/tests/`. Pure round-trip
163// tests on these types would just be testing serde, which the project
164// rule `test-ownership.md` excludes.