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 from_validation_error(error: &ValidationError) -> Self {
124 let error_count = error.errors.len();
125 let detail = if error_count == 1 {
126 "1 validation error in request".to_string()
127 } else {
128 format!("{} validation errors in request", error_count)
129 };
130
131 let errors_json = serde_json::to_value(&error.errors).unwrap_or_else(|_| serde_json::Value::Array(vec![]));
132
133 Self::new(
134 Self::TYPE_VALIDATION_ERROR,
135 "Request Validation Failed",
136 StatusCode::UNPROCESSABLE_ENTITY,
137 )
138 .with_detail(detail)
139 .with_extension("errors", errors_json)
140 }
141
142 pub fn not_found(detail: impl Into<String>) -> Self {
144 Self::new(Self::TYPE_NOT_FOUND, "Resource Not Found", StatusCode::NOT_FOUND).with_detail(detail)
145 }
146
147 pub fn method_not_allowed(detail: impl Into<String>) -> Self {
149 Self::new(
150 Self::TYPE_METHOD_NOT_ALLOWED,
151 "Method Not Allowed",
152 StatusCode::METHOD_NOT_ALLOWED,
153 )
154 .with_detail(detail)
155 }
156
157 pub fn internal_server_error(detail: impl Into<String>) -> Self {
159 Self::new(
160 Self::TYPE_INTERNAL_SERVER_ERROR,
161 "Internal Server Error",
162 StatusCode::INTERNAL_SERVER_ERROR,
163 )
164 .with_detail(detail)
165 }
166
167 pub fn internal_server_error_debug(
172 detail: impl Into<String>,
173 exception: impl Into<String>,
174 traceback: impl Into<String>,
175 request_data: Value,
176 ) -> Self {
177 Self::new(
178 Self::TYPE_INTERNAL_SERVER_ERROR,
179 "Internal Server Error",
180 StatusCode::INTERNAL_SERVER_ERROR,
181 )
182 .with_detail(detail)
183 .with_extension("exception", Value::String(exception.into()))
184 .with_extension("traceback", Value::String(traceback.into()))
185 .with_extension("request_data", request_data)
186 }
187
188 pub fn bad_request(detail: impl Into<String>) -> Self {
190 Self::new(Self::TYPE_BAD_REQUEST, "Bad Request", StatusCode::BAD_REQUEST).with_detail(detail)
191 }
192
193 pub fn status_code(&self) -> StatusCode {
195 StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
196 }
197
198 pub fn to_json(&self) -> Result<String, serde_json::Error> {
200 serde_json::to_string(self)
201 }
202
203 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
205 serde_json::to_string_pretty(self)
206 }
207}
208
209pub const CONTENT_TYPE_PROBLEM_JSON: &str = "application/problem+json; charset=utf-8";
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::validation::{ValidationError, ValidationErrorDetail};
216 use serde_json::json;
217
218 #[test]
219 fn test_validation_error_conversion() {
220 let validation_error = ValidationError {
221 errors: vec![
222 ValidationErrorDetail {
223 error_type: "missing".to_string(),
224 loc: vec!["body".to_string(), "username".to_string()],
225 msg: "Field required".to_string(),
226 input: Value::String("".to_string()),
227 ctx: None,
228 },
229 ValidationErrorDetail {
230 error_type: "string_too_short".to_string(),
231 loc: vec!["body".to_string(), "password".to_string()],
232 msg: "String should have at least 8 characters".to_string(),
233 input: Value::String("pass".to_string()),
234 ctx: Some(json!({"min_length": 8})),
235 },
236 ],
237 };
238
239 let problem = ProblemDetails::from_validation_error(&validation_error);
240
241 assert_eq!(problem.type_uri, ProblemDetails::TYPE_VALIDATION_ERROR);
242 assert_eq!(problem.title, "Request Validation Failed");
243 assert_eq!(problem.status, 422);
244 assert_eq!(problem.detail, Some("2 validation errors in request".to_string()));
245
246 let errors = problem.extensions.get("errors").unwrap();
247 assert!(errors.is_array());
248 assert_eq!(errors.as_array().unwrap().len(), 2);
249 }
250
251 #[test]
252 fn test_problem_details_serialization() {
253 let problem = ProblemDetails::new(
254 "https://example.com/probs/out-of-credit",
255 "You do not have enough credit",
256 StatusCode::FORBIDDEN,
257 )
258 .with_detail("Your current balance is 30, but that costs 50.")
259 .with_instance("/account/12345/msgs/abc")
260 .with_extension("balance", json!(30))
261 .with_extension("accounts", json!(["/account/12345", "/account/67890"]));
262
263 let json_str = problem.to_json_pretty().unwrap();
264 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
265
266 assert_eq!(parsed["type"], "https://example.com/probs/out-of-credit");
267 assert_eq!(parsed["title"], "You do not have enough credit");
268 assert_eq!(parsed["status"], 403);
269 assert_eq!(parsed["detail"], "Your current balance is 30, but that costs 50.");
270 assert_eq!(parsed["instance"], "/account/12345/msgs/abc");
271 assert_eq!(parsed["balance"], 30);
272 }
273
274 #[test]
275 fn test_not_found_error() {
276 let problem = ProblemDetails::not_found("No route matches GET /api/users/999");
277
278 assert_eq!(problem.type_uri, ProblemDetails::TYPE_NOT_FOUND);
279 assert_eq!(problem.title, "Resource Not Found");
280 assert_eq!(problem.status, 404);
281 assert_eq!(problem.detail, Some("No route matches GET /api/users/999".to_string()));
282 }
283
284 #[test]
285 fn test_internal_server_error_debug() {
286 let request_data = json!({
287 "path_params": {},
288 "query_params": {},
289 "body": {"username": "test"}
290 });
291
292 let problem = ProblemDetails::internal_server_error_debug(
293 "Python handler raised KeyError",
294 "KeyError: 'username'",
295 "Traceback (most recent call last):\n ...",
296 request_data,
297 );
298
299 assert_eq!(problem.type_uri, ProblemDetails::TYPE_INTERNAL_SERVER_ERROR);
300 assert_eq!(problem.status, 500);
301 assert!(problem.extensions.contains_key("exception"));
302 assert!(problem.extensions.contains_key("traceback"));
303 assert!(problem.extensions.contains_key("request_data"));
304 }
305
306 #[test]
307 fn test_content_type_constant() {
308 assert_eq!(CONTENT_TYPE_PROBLEM_JSON, "application/problem+json; charset=utf-8");
309 }
310}