1use crate::validation::ValidationError;
11use http::StatusCode;
12use serde::Serialize;
13use serde_json::Value;
14use std::collections::HashMap;
15
16#[derive(Debug, Clone, Serialize)]
39pub struct ProblemDetails {
40 #[serde(rename = "type")]
44 pub type_uri: String,
45
46 pub title: String,
49
50 pub status: u16,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub detail: Option<String>,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
61 pub instance: Option<String>,
62
63 #[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
66 pub extensions: HashMap<String, Value>,
67}
68
69impl ProblemDetails {
70 pub const TYPE_VALIDATION_ERROR: &'static str = "https://spikard.dev/errors/validation-error";
72
73 pub const TYPE_NOT_FOUND: &'static str = "https://spikard.dev/errors/not-found";
75
76 pub const TYPE_METHOD_NOT_ALLOWED: &'static str = "https://spikard.dev/errors/method-not-allowed";
78
79 pub const TYPE_INTERNAL_SERVER_ERROR: &'static str = "https://spikard.dev/errors/internal-server-error";
81
82 pub const TYPE_BAD_REQUEST: &'static str = "https://spikard.dev/errors/bad-request";
84
85 #[must_use]
87 pub fn new(type_uri: impl Into<String>, title: impl Into<String>, status: StatusCode) -> Self {
88 Self {
89 type_uri: type_uri.into(),
90 title: title.into(),
91 status: status.as_u16(),
92 detail: None,
93 instance: None,
94 extensions: HashMap::new(),
95 }
96 }
97
98 #[must_use]
100 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
101 self.detail = Some(detail.into());
102 self
103 }
104
105 #[must_use]
107 pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
108 self.instance = Some(instance.into());
109 self
110 }
111
112 #[must_use]
114 pub fn with_extension(mut self, key: impl Into<String>, value: Value) -> Self {
115 self.extensions.insert(key.into(), value);
116 self
117 }
118
119 #[must_use]
121 #[allow(clippy::needless_pass_by_value)]
122 pub fn with_extensions(mut self, extensions: Value) -> Self {
123 if let Some(obj) = extensions.as_object() {
124 for (key, value) in obj {
125 self.extensions.insert(key.clone(), value.clone());
126 }
127 }
128 self
129 }
130
131 #[must_use]
140 pub fn from_validation_error(error: &ValidationError) -> Self {
141 let error_count = error.errors.len();
142 let detail = if error_count == 1 {
143 "1 validation error in request".to_string()
144 } else {
145 format!("{error_count} validation errors in request")
146 };
147
148 let errors_json = serde_json::to_value(&error.errors).unwrap_or_else(|_| serde_json::Value::Array(vec![]));
149
150 Self::new(
151 Self::TYPE_VALIDATION_ERROR,
152 "Request Validation Failed",
153 StatusCode::UNPROCESSABLE_ENTITY,
154 )
155 .with_detail(detail)
156 .with_extension("errors", errors_json)
157 }
158
159 pub fn not_found(detail: impl Into<String>) -> Self {
161 Self::new(Self::TYPE_NOT_FOUND, "Resource Not Found", StatusCode::NOT_FOUND).with_detail(detail)
162 }
163
164 pub fn method_not_allowed(detail: impl Into<String>) -> Self {
166 Self::new(
167 Self::TYPE_METHOD_NOT_ALLOWED,
168 "Method Not Allowed",
169 StatusCode::METHOD_NOT_ALLOWED,
170 )
171 .with_detail(detail)
172 }
173
174 pub fn internal_server_error(detail: impl Into<String>) -> Self {
176 Self::new(
177 Self::TYPE_INTERNAL_SERVER_ERROR,
178 "Internal Server Error",
179 StatusCode::INTERNAL_SERVER_ERROR,
180 )
181 .with_detail(detail)
182 }
183
184 pub fn internal_server_error_debug(
189 detail: impl Into<String>,
190 exception: impl Into<String>,
191 traceback: impl Into<String>,
192 request_data: Value,
193 ) -> Self {
194 Self::new(
195 Self::TYPE_INTERNAL_SERVER_ERROR,
196 "Internal Server Error",
197 StatusCode::INTERNAL_SERVER_ERROR,
198 )
199 .with_detail(detail)
200 .with_extension("exception", Value::String(exception.into()))
201 .with_extension("traceback", Value::String(traceback.into()))
202 .with_extension("request_data", request_data)
203 }
204
205 pub fn bad_request(detail: impl Into<String>) -> Self {
207 Self::new(Self::TYPE_BAD_REQUEST, "Bad Request", StatusCode::BAD_REQUEST).with_detail(detail)
208 }
209
210 #[must_use]
212 pub fn status_code(&self) -> StatusCode {
213 StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
214 }
215
216 pub fn to_json(&self) -> Result<String, serde_json::Error> {
221 serde_json::to_string(self)
222 }
223
224 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
229 serde_json::to_string_pretty(self)
230 }
231}
232
233pub const CONTENT_TYPE_PROBLEM_JSON: &str = "application/problem+json; charset=utf-8";
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use crate::validation::{ValidationError, ValidationErrorDetail};
240 use serde_json::json;
241
242 #[test]
243 fn test_validation_error_conversion() {
244 let validation_error = ValidationError {
245 errors: vec![
246 ValidationErrorDetail {
247 error_type: "missing".to_string(),
248 loc: vec!["body".to_string(), "username".to_string()],
249 msg: "Field required".to_string(),
250 input: Value::String("".to_string()),
251 ctx: None,
252 },
253 ValidationErrorDetail {
254 error_type: "string_too_short".to_string(),
255 loc: vec!["body".to_string(), "password".to_string()],
256 msg: "String should have at least 8 characters".to_string(),
257 input: Value::String("pass".to_string()),
258 ctx: Some(json!({"min_length": 8})),
259 },
260 ],
261 };
262
263 let problem = ProblemDetails::from_validation_error(&validation_error);
264
265 assert_eq!(problem.type_uri, ProblemDetails::TYPE_VALIDATION_ERROR);
266 assert_eq!(problem.title, "Request Validation Failed");
267 assert_eq!(problem.status, 422);
268 assert_eq!(problem.detail, Some("2 validation errors in request".to_string()));
269
270 let errors = problem.extensions.get("errors").unwrap();
271 assert!(errors.is_array());
272 assert_eq!(errors.as_array().unwrap().len(), 2);
273 }
274
275 #[test]
276 fn test_problem_details_serialization() {
277 let problem = ProblemDetails::new(
278 "https://example.com/probs/out-of-credit",
279 "You do not have enough credit",
280 StatusCode::FORBIDDEN,
281 )
282 .with_detail("Your current balance is 30, but that costs 50.")
283 .with_instance("/account/12345/msgs/abc")
284 .with_extension("balance", json!(30))
285 .with_extension("accounts", json!(["/account/12345", "/account/67890"]));
286
287 let json_str = problem.to_json_pretty().unwrap();
288 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
289
290 assert_eq!(parsed["type"], "https://example.com/probs/out-of-credit");
291 assert_eq!(parsed["title"], "You do not have enough credit");
292 assert_eq!(parsed["status"], 403);
293 assert_eq!(parsed["detail"], "Your current balance is 30, but that costs 50.");
294 assert_eq!(parsed["instance"], "/account/12345/msgs/abc");
295 assert_eq!(parsed["balance"], 30);
296 }
297
298 #[test]
299 fn test_not_found_error() {
300 let problem = ProblemDetails::not_found("No route matches GET /api/users/999");
301
302 assert_eq!(problem.type_uri, ProblemDetails::TYPE_NOT_FOUND);
303 assert_eq!(problem.title, "Resource Not Found");
304 assert_eq!(problem.status, 404);
305 assert_eq!(problem.detail, Some("No route matches GET /api/users/999".to_string()));
306 }
307
308 #[test]
309 fn test_internal_server_error_debug() {
310 let request_data = json!({
311 "path_params": {},
312 "query_params": {},
313 "body": {"username": "test"}
314 });
315
316 let problem = ProblemDetails::internal_server_error_debug(
317 "Python handler raised KeyError",
318 "KeyError: 'username'",
319 "Traceback (most recent call last):\n ...",
320 request_data,
321 );
322
323 assert_eq!(problem.type_uri, ProblemDetails::TYPE_INTERNAL_SERVER_ERROR);
324 assert_eq!(problem.status, 500);
325 assert!(problem.extensions.contains_key("exception"));
326 assert!(problem.extensions.contains_key("traceback"));
327 assert!(problem.extensions.contains_key("request_data"));
328 }
329
330 #[test]
331 fn test_validation_error_conversion_single_error_uses_singular_detail() {
332 let validation_error = ValidationError {
333 errors: vec![ValidationErrorDetail {
334 error_type: "missing".to_string(),
335 loc: vec!["query".to_string(), "id".to_string()],
336 msg: "Field required".to_string(),
337 input: Value::Null,
338 ctx: None,
339 }],
340 };
341
342 let problem = ProblemDetails::from_validation_error(&validation_error);
343 assert_eq!(problem.status, 422);
344 assert_eq!(problem.detail, Some("1 validation error in request".to_string()));
345 }
346
347 #[test]
348 fn test_with_extensions_ignores_non_object_values() {
349 let problem =
350 ProblemDetails::new("about:blank", "Test", StatusCode::BAD_REQUEST).with_extensions(Value::Bool(true));
351 assert!(problem.extensions.is_empty());
352 }
353
354 #[test]
355 fn test_content_type_constant() {
356 assert_eq!(CONTENT_TYPE_PROBLEM_JSON, "application/problem+json; charset=utf-8");
357 }
358}