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 pub fn new(type_uri: impl Into<String>, title: impl Into<String>, status: StatusCode) -> Self {
87 Self {
88 type_uri: type_uri.into(),
89 title: title.into(),
90 status: status.as_u16(),
91 detail: None,
92 instance: None,
93 extensions: HashMap::new(),
94 }
95 }
96
97 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
99 self.detail = Some(detail.into());
100 self
101 }
102
103 pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
105 self.instance = Some(instance.into());
106 self
107 }
108
109 pub fn with_extension(mut self, key: impl Into<String>, value: Value) -> Self {
111 self.extensions.insert(key.into(), value);
112 self
113 }
114
115 pub fn with_extensions(mut self, extensions: Value) -> Self {
117 if let Some(obj) = extensions.as_object() {
118 for (key, value) in obj {
119 self.extensions.insert(key.clone(), value.clone());
120 }
121 }
122 self
123 }
124
125 pub fn from_validation_error(error: &ValidationError) -> Self {
134 let error_count = error.errors.len();
135 let detail = if error_count == 1 {
136 "1 validation error in request".to_string()
137 } else {
138 format!("{} validation errors in request", error_count)
139 };
140
141 let errors_json = serde_json::to_value(&error.errors).unwrap_or_else(|_| serde_json::Value::Array(vec![]));
142
143 Self::new(
144 Self::TYPE_VALIDATION_ERROR,
145 "Request Validation Failed",
146 StatusCode::UNPROCESSABLE_ENTITY,
147 )
148 .with_detail(detail)
149 .with_extension("errors", errors_json)
150 }
151
152 pub fn not_found(detail: impl Into<String>) -> Self {
154 Self::new(Self::TYPE_NOT_FOUND, "Resource Not Found", StatusCode::NOT_FOUND).with_detail(detail)
155 }
156
157 pub fn method_not_allowed(detail: impl Into<String>) -> Self {
159 Self::new(
160 Self::TYPE_METHOD_NOT_ALLOWED,
161 "Method Not Allowed",
162 StatusCode::METHOD_NOT_ALLOWED,
163 )
164 .with_detail(detail)
165 }
166
167 pub fn internal_server_error(detail: impl Into<String>) -> Self {
169 Self::new(
170 Self::TYPE_INTERNAL_SERVER_ERROR,
171 "Internal Server Error",
172 StatusCode::INTERNAL_SERVER_ERROR,
173 )
174 .with_detail(detail)
175 }
176
177 pub fn internal_server_error_debug(
182 detail: impl Into<String>,
183 exception: impl Into<String>,
184 traceback: impl Into<String>,
185 request_data: Value,
186 ) -> Self {
187 Self::new(
188 Self::TYPE_INTERNAL_SERVER_ERROR,
189 "Internal Server Error",
190 StatusCode::INTERNAL_SERVER_ERROR,
191 )
192 .with_detail(detail)
193 .with_extension("exception", Value::String(exception.into()))
194 .with_extension("traceback", Value::String(traceback.into()))
195 .with_extension("request_data", request_data)
196 }
197
198 pub fn bad_request(detail: impl Into<String>) -> Self {
200 Self::new(Self::TYPE_BAD_REQUEST, "Bad Request", StatusCode::BAD_REQUEST).with_detail(detail)
201 }
202
203 pub fn status_code(&self) -> StatusCode {
205 StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
206 }
207
208 pub fn to_json(&self) -> Result<String, serde_json::Error> {
210 serde_json::to_string(self)
211 }
212
213 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
215 serde_json::to_string_pretty(self)
216 }
217}
218
219pub const CONTENT_TYPE_PROBLEM_JSON: &str = "application/problem+json; charset=utf-8";
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::validation::{ValidationError, ValidationErrorDetail};
226 use serde_json::json;
227
228 #[test]
229 fn test_validation_error_conversion() {
230 let validation_error = ValidationError {
231 errors: vec![
232 ValidationErrorDetail {
233 error_type: "missing".to_string(),
234 loc: vec!["body".to_string(), "username".to_string()],
235 msg: "Field required".to_string(),
236 input: Value::String("".to_string()),
237 ctx: None,
238 },
239 ValidationErrorDetail {
240 error_type: "string_too_short".to_string(),
241 loc: vec!["body".to_string(), "password".to_string()],
242 msg: "String should have at least 8 characters".to_string(),
243 input: Value::String("pass".to_string()),
244 ctx: Some(json!({"min_length": 8})),
245 },
246 ],
247 };
248
249 let problem = ProblemDetails::from_validation_error(&validation_error);
250
251 assert_eq!(problem.type_uri, ProblemDetails::TYPE_VALIDATION_ERROR);
252 assert_eq!(problem.title, "Request Validation Failed");
253 assert_eq!(problem.status, 422);
254 assert_eq!(problem.detail, Some("2 validation errors in request".to_string()));
255
256 let errors = problem.extensions.get("errors").unwrap();
257 assert!(errors.is_array());
258 assert_eq!(errors.as_array().unwrap().len(), 2);
259 }
260
261 #[test]
262 fn test_problem_details_serialization() {
263 let problem = ProblemDetails::new(
264 "https://example.com/probs/out-of-credit",
265 "You do not have enough credit",
266 StatusCode::FORBIDDEN,
267 )
268 .with_detail("Your current balance is 30, but that costs 50.")
269 .with_instance("/account/12345/msgs/abc")
270 .with_extension("balance", json!(30))
271 .with_extension("accounts", json!(["/account/12345", "/account/67890"]));
272
273 let json_str = problem.to_json_pretty().unwrap();
274 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
275
276 assert_eq!(parsed["type"], "https://example.com/probs/out-of-credit");
277 assert_eq!(parsed["title"], "You do not have enough credit");
278 assert_eq!(parsed["status"], 403);
279 assert_eq!(parsed["detail"], "Your current balance is 30, but that costs 50.");
280 assert_eq!(parsed["instance"], "/account/12345/msgs/abc");
281 assert_eq!(parsed["balance"], 30);
282 }
283
284 #[test]
285 fn test_not_found_error() {
286 let problem = ProblemDetails::not_found("No route matches GET /api/users/999");
287
288 assert_eq!(problem.type_uri, ProblemDetails::TYPE_NOT_FOUND);
289 assert_eq!(problem.title, "Resource Not Found");
290 assert_eq!(problem.status, 404);
291 assert_eq!(problem.detail, Some("No route matches GET /api/users/999".to_string()));
292 }
293
294 #[test]
295 fn test_internal_server_error_debug() {
296 let request_data = json!({
297 "path_params": {},
298 "query_params": {},
299 "body": {"username": "test"}
300 });
301
302 let problem = ProblemDetails::internal_server_error_debug(
303 "Python handler raised KeyError",
304 "KeyError: 'username'",
305 "Traceback (most recent call last):\n ...",
306 request_data,
307 );
308
309 assert_eq!(problem.type_uri, ProblemDetails::TYPE_INTERNAL_SERVER_ERROR);
310 assert_eq!(problem.status, 500);
311 assert!(problem.extensions.contains_key("exception"));
312 assert!(problem.extensions.contains_key("traceback"));
313 assert!(problem.extensions.contains_key("request_data"));
314 }
315
316 #[test]
317 fn test_validation_error_conversion_single_error_uses_singular_detail() {
318 let validation_error = ValidationError {
319 errors: vec![ValidationErrorDetail {
320 error_type: "missing".to_string(),
321 loc: vec!["query".to_string(), "id".to_string()],
322 msg: "Field required".to_string(),
323 input: Value::Null,
324 ctx: None,
325 }],
326 };
327
328 let problem = ProblemDetails::from_validation_error(&validation_error);
329 assert_eq!(problem.status, 422);
330 assert_eq!(problem.detail, Some("1 validation error in request".to_string()));
331 }
332
333 #[test]
334 fn test_with_extensions_ignores_non_object_values() {
335 let problem =
336 ProblemDetails::new("about:blank", "Test", StatusCode::BAD_REQUEST).with_extensions(Value::Bool(true));
337 assert!(problem.extensions.is_empty());
338 }
339
340 #[test]
341 fn test_content_type_constant() {
342 assert_eq!(CONTENT_TYPE_PROBLEM_JSON, "application/problem+json; charset=utf-8");
343 }
344}