1use http::StatusCode;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use serde_json::Value;
6
7#[cfg(feature = "utoipa")]
8use utoipa::ToSchema;
9
10pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
12
13#[allow(clippy::trivially_copy_pass_by_ref)] fn serialize_status_code<S>(status: &StatusCode, serializer: S) -> Result<S::Ok, S::Error>
16where
17 S: Serializer,
18{
19 serializer.serialize_u16(status.as_u16())
20}
21
22fn deserialize_status_code<'de, D>(deserializer: D) -> Result<StatusCode, D::Error>
24where
25 D: Deserializer<'de>,
26{
27 let code = u16::deserialize(deserializer)?;
28 StatusCode::from_u16(code).map_err(serde::de::Error::custom)
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33#[cfg_attr(feature = "utoipa", derive(ToSchema))]
34#[cfg_attr(
35 feature = "utoipa",
36 schema(
37 title = "Problem",
38 description = "RFC 9457 Problem Details for HTTP APIs"
39 )
40)]
41#[must_use]
42pub struct Problem {
43 #[serde(rename = "type")]
46 pub type_url: String,
47 pub title: String,
49 #[serde(
52 serialize_with = "serialize_status_code",
53 deserialize_with = "deserialize_status_code"
54 )]
55 #[cfg_attr(feature = "utoipa", schema(value_type = u16))]
56 pub status: StatusCode,
57 pub detail: String,
59 #[serde(default, skip_serializing_if = "String::is_empty")]
61 pub instance: String,
62 #[serde(default, skip_serializing_if = "String::is_empty")]
64 pub code: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub trace_id: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub errors: Option<Vec<ValidationViolation>>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub context: Option<Value>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78#[cfg_attr(feature = "utoipa", derive(ToSchema))]
79#[cfg_attr(feature = "utoipa", schema(title = "ValidationViolation"))]
80pub struct ValidationViolation {
81 pub field: String,
83 pub message: String,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub code: Option<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92#[cfg_attr(feature = "utoipa", derive(ToSchema))]
93#[cfg_attr(feature = "utoipa", schema(title = "ValidationError"))]
94pub struct ValidationError {
95 pub errors: Vec<ValidationViolation>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101#[cfg_attr(feature = "utoipa", derive(ToSchema))]
102#[cfg_attr(feature = "utoipa", schema(title = "ValidationErrorResponse"))]
103pub struct ValidationErrorResponse {
104 #[serde(flatten)]
106 pub validation: ValidationError,
107}
108
109impl Problem {
110 pub fn new(status: StatusCode, title: impl Into<String>, detail: impl Into<String>) -> Self {
115 Self {
116 type_url: "about:blank".to_owned(),
117 title: title.into(),
118 status,
119 detail: detail.into(),
120 instance: String::new(),
121 code: String::new(),
122 trace_id: None,
123 errors: None,
124 context: None,
125 }
126 }
127
128 pub fn with_type(mut self, type_url: impl Into<String>) -> Self {
129 self.type_url = type_url.into();
130 self
131 }
132
133 pub fn with_instance(mut self, uri: impl Into<String>) -> Self {
134 self.instance = uri.into();
135 self
136 }
137
138 pub fn with_code(mut self, code: impl Into<String>) -> Self {
139 self.code = code.into();
140 self
141 }
142
143 pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
144 self.trace_id = Some(id.into());
145 self
146 }
147
148 pub fn with_errors(mut self, errors: Vec<ValidationViolation>) -> Self {
149 self.errors = Some(errors);
150 self
151 }
152
153 pub fn with_context(mut self, context: Value) -> Self {
154 self.context = Some(context);
155 self
156 }
157}
158
159#[cfg(feature = "axum")]
164impl axum::response::IntoResponse for Problem {
165 fn into_response(self) -> axum::response::Response {
166 use axum::http::HeaderValue;
167
168 let problem = if self.trace_id.is_none() {
170 match tracing::Span::current().id() {
171 Some(span_id) => self.with_trace_id(span_id.into_u64().to_string()),
172 _ => self,
173 }
174 } else {
175 self
176 };
177
178 let status = problem.status;
179 let mut resp = axum::Json(problem).into_response();
180 *resp.status_mut() = status;
181 resp.headers_mut().insert(
182 axum::http::header::CONTENT_TYPE,
183 HeaderValue::from_static(APPLICATION_PROBLEM_JSON),
184 );
185 resp
186 }
187}
188
189#[cfg(test)]
190#[cfg_attr(coverage_nightly, coverage(off))]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn problem_builder_pattern() {
196 let p = Problem::new(
197 StatusCode::UNPROCESSABLE_ENTITY,
198 "Validation Failed",
199 "Input validation errors",
200 )
201 .with_code("VALIDATION_ERROR")
202 .with_instance("/users/123")
203 .with_trace_id("req-456")
204 .with_errors(vec![ValidationViolation {
205 message: "Email is required".to_owned(),
206 field: "email".to_owned(),
207 code: None,
208 }]);
209
210 assert_eq!(p.status, StatusCode::UNPROCESSABLE_ENTITY);
211 assert_eq!(p.code, "VALIDATION_ERROR");
212 assert_eq!(p.instance, "/users/123");
213 assert_eq!(p.trace_id, Some("req-456".to_owned()));
214 assert!(p.errors.is_some());
215 assert_eq!(p.errors.as_ref().unwrap().len(), 1);
216 }
217
218 #[test]
219 fn problem_serializes_status_as_u16() {
220 let p = Problem::new(StatusCode::NOT_FOUND, "Not Found", "Resource not found");
221 let json = serde_json::to_string(&p).unwrap();
222 assert!(json.contains("\"status\":404"));
223 }
224
225 #[test]
226 fn problem_deserializes_status_from_u16() {
227 let json = r#"{"type":"about:blank","title":"Not Found","status":404,"detail":"Resource not found"}"#;
228 let p: Problem = serde_json::from_str(json).unwrap();
229 assert_eq!(p.status, StatusCode::NOT_FOUND);
230 }
231}