1use crate::validation::ValidationError;
11use http::StatusCode;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
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, serde_json::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)]
124 pub fn with_extensions(mut self, extensions: Value) -> Self {
125 if let Some(obj) = extensions.as_object() {
126 for (key, value) in obj {
127 self.extensions.insert(key.clone(), value.clone());
128 }
129 }
130 self
131 }
132
133 #[must_use]
142 pub fn from_validation_error(error: &ValidationError) -> Self {
143 let error_count = error.errors.len();
144 let detail = if error_count == 1 {
145 "1 validation error in request".to_string()
146 } else {
147 format!("{error_count} validation errors in request")
148 };
149
150 let errors_json = serde_json::to_value(&error.errors).unwrap_or_else(|_| serde_json::Value::Array(vec![]));
151
152 Self::new(
153 Self::TYPE_VALIDATION_ERROR,
154 "Request Validation Failed",
155 StatusCode::UNPROCESSABLE_ENTITY,
156 )
157 .with_detail(detail)
158 .with_extension("errors", errors_json)
159 }
160
161 pub fn not_found(detail: impl Into<String>) -> Self {
163 Self::new(Self::TYPE_NOT_FOUND, "Resource Not Found", StatusCode::NOT_FOUND).with_detail(detail)
164 }
165
166 pub fn method_not_allowed(detail: impl Into<String>) -> Self {
168 Self::new(
169 Self::TYPE_METHOD_NOT_ALLOWED,
170 "Method Not Allowed",
171 StatusCode::METHOD_NOT_ALLOWED,
172 )
173 .with_detail(detail)
174 }
175
176 pub fn internal_server_error(detail: impl Into<String>) -> Self {
178 Self::new(
179 Self::TYPE_INTERNAL_SERVER_ERROR,
180 "Internal Server Error",
181 StatusCode::INTERNAL_SERVER_ERROR,
182 )
183 .with_detail(detail)
184 }
185
186 pub fn internal_server_error_debug(
191 detail: impl Into<String>,
192 exception: impl Into<String>,
193 traceback: impl Into<String>,
194 request_data: Value,
195 ) -> Self {
196 Self::new(
197 Self::TYPE_INTERNAL_SERVER_ERROR,
198 "Internal Server Error",
199 StatusCode::INTERNAL_SERVER_ERROR,
200 )
201 .with_detail(detail)
202 .with_extension("exception", Value::String(exception.into()))
203 .with_extension("traceback", Value::String(traceback.into()))
204 .with_extension("request_data", request_data)
205 }
206
207 pub fn bad_request(detail: impl Into<String>) -> Self {
209 Self::new(Self::TYPE_BAD_REQUEST, "Bad Request", StatusCode::BAD_REQUEST).with_detail(detail)
210 }
211
212 #[must_use]
214 pub fn status_code(&self) -> StatusCode {
215 StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
216 }
217
218 pub fn to_json(&self) -> Result<String, serde_json::Error> {
223 serde_json::to_string(self)
224 }
225
226 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
231 serde_json::to_string_pretty(self)
232 }
233}
234
235pub const CONTENT_TYPE_PROBLEM_JSON: &str = "application/problem+json; charset=utf-8";
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::validation::{ValidationError, ValidationErrorDetail};
242 use serde_json::json;
243
244 #[test]
245 fn test_validation_error_conversion() {
246 let validation_error = ValidationError {
247 errors: vec![
248 ValidationErrorDetail {
249 error_type: "missing".to_string(),
250 loc: vec!["body".to_string(), "username".to_string()],
251 msg: "Field required".to_string(),
252 input: Value::String(String::new()),
253 ctx: None,
254 },
255 ValidationErrorDetail {
256 error_type: "string_too_short".to_string(),
257 loc: vec!["body".to_string(), "password".to_string()],
258 msg: "String should have at least 8 characters".to_string(),
259 input: Value::String("pass".to_string()),
260 ctx: Some(json!({"min_length": 8})),
261 },
262 ],
263 };
264
265 let problem = ProblemDetails::from_validation_error(&validation_error);
266
267 assert_eq!(problem.type_uri, ProblemDetails::TYPE_VALIDATION_ERROR);
268 assert_eq!(problem.title, "Request Validation Failed");
269 assert_eq!(problem.status, 422);
270 assert_eq!(problem.detail, Some("2 validation errors in request".to_string()));
271
272 let errors = problem.extensions.get("errors").unwrap();
273 assert!(errors.is_array());
274 assert_eq!(errors.as_array().unwrap().len(), 2);
275 }
276
277 #[test]
278 fn test_problem_details_serialization() {
279 let problem = ProblemDetails::new(
280 "https://example.com/probs/out-of-credit",
281 "You do not have enough credit",
282 StatusCode::FORBIDDEN,
283 )
284 .with_detail("Your current balance is 30, but that costs 50.")
285 .with_instance("/account/12345/msgs/abc")
286 .with_extension("balance", json!(30))
287 .with_extension("accounts", json!(["/account/12345", "/account/67890"]));
288
289 let json_str = problem.to_json_pretty().unwrap();
290 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
291
292 assert_eq!(parsed["type"], "https://example.com/probs/out-of-credit");
293 assert_eq!(parsed["title"], "You do not have enough credit");
294 assert_eq!(parsed["status"], 403);
295 assert_eq!(parsed["detail"], "Your current balance is 30, but that costs 50.");
296 assert_eq!(parsed["instance"], "/account/12345/msgs/abc");
297 assert_eq!(parsed["balance"], 30);
298 }
299
300 #[test]
301 fn test_not_found_error() {
302 let problem = ProblemDetails::not_found("No route matches GET /api/users/999");
303
304 assert_eq!(problem.type_uri, ProblemDetails::TYPE_NOT_FOUND);
305 assert_eq!(problem.title, "Resource Not Found");
306 assert_eq!(problem.status, 404);
307 assert_eq!(problem.detail, Some("No route matches GET /api/users/999".to_string()));
308 }
309
310 #[test]
311 fn test_internal_server_error_debug() {
312 let request_data = json!({
313 "path_params": {},
314 "query_params": {},
315 "body": {"username": "test"}
316 });
317
318 let problem = ProblemDetails::internal_server_error_debug(
319 "Python handler raised KeyError",
320 "KeyError: 'username'",
321 "Traceback (most recent call last):\n ...",
322 request_data,
323 );
324
325 assert_eq!(problem.type_uri, ProblemDetails::TYPE_INTERNAL_SERVER_ERROR);
326 assert_eq!(problem.status, 500);
327 assert!(problem.extensions.contains_key("exception"));
328 assert!(problem.extensions.contains_key("traceback"));
329 assert!(problem.extensions.contains_key("request_data"));
330 }
331
332 #[test]
333 fn test_validation_error_conversion_single_error_uses_singular_detail() {
334 let validation_error = ValidationError {
335 errors: vec![ValidationErrorDetail {
336 error_type: "missing".to_string(),
337 loc: vec!["query".to_string(), "id".to_string()],
338 msg: "Field required".to_string(),
339 input: Value::Null,
340 ctx: None,
341 }],
342 };
343
344 let problem = ProblemDetails::from_validation_error(&validation_error);
345 assert_eq!(problem.status, 422);
346 assert_eq!(problem.detail, Some("1 validation error in request".to_string()));
347 }
348
349 #[test]
350 fn test_with_extensions_ignores_non_object_values() {
351 let problem =
352 ProblemDetails::new("about:blank", "Test", StatusCode::BAD_REQUEST).with_extensions(Value::Bool(true));
353 assert!(problem.extensions.is_empty());
354 }
355
356 #[test]
357 fn test_content_type_constant() {
358 assert_eq!(CONTENT_TYPE_PROBLEM_JSON, "application/problem+json; charset=utf-8");
359 }
360}