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