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.