spikard_core/
problem.rs

1//! RFC 9457 Problem Details for HTTP APIs
2//!
3//! Implements the standard Problem Details format (RFC 9457, July 2023) for HTTP error responses.
4//! This replaces framework-specific error formats with the IETF standard.
5//!
6//! # References
7//! - [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html)
8//! - [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html)
9
10use crate::validation::ValidationError;
11use http::StatusCode;
12use serde::Serialize;
13use serde_json::Value;
14use std::collections::HashMap;
15
16/// RFC 9457 Problem Details for HTTP APIs
17///
18/// A machine-readable format for specifying errors in HTTP API responses.
19/// Per RFC 9457, all fields are optional. The `type` field defaults to "about:blank"
20/// if not specified.
21///
22/// # Content-Type
23/// Responses using this struct should set:
24/// ```text
25/// Content-Type: application/problem+json
26/// ```
27///
28/// # Example
29/// ```json
30/// {
31///   "type": "https://spikard.dev/errors/validation-error",
32///   "title": "Request Validation Failed",
33///   "status": 422,
34///   "detail": "2 validation errors in request body",
35///   "errors": [...]
36/// }
37/// ```
38#[derive(Debug, Clone, Serialize)]
39pub struct ProblemDetails {
40    /// A URI reference that identifies the problem type.
41    /// Defaults to "about:blank" when absent.
42    /// Should be a stable, human-readable identifier for the problem type.
43    #[serde(rename = "type")]
44    pub type_uri: String,
45
46    /// A short, human-readable summary of the problem type.
47    /// Should not change from occurrence to occurrence of the problem.
48    pub title: String,
49
50    /// The HTTP status code generated by the origin server.
51    /// This is advisory; the actual HTTP status code takes precedence.
52    pub status: u16,
53
54    /// A human-readable explanation specific to this occurrence of the problem.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub detail: Option<String>,
57
58    /// A URI reference that identifies the specific occurrence of the problem.
59    /// It may or may not yield further information if dereferenced.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub instance: Option<String>,
62
63    /// Extension members - problem-type-specific data.
64    /// For validation errors, this typically contains an "errors" array.
65    #[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
66    pub extensions: HashMap<String, Value>,
67}
68
69impl ProblemDetails {
70    /// Standard type URI for validation errors (422)
71    pub const TYPE_VALIDATION_ERROR: &'static str = "https://spikard.dev/errors/validation-error";
72
73    /// Standard type URI for not found errors (404)
74    pub const TYPE_NOT_FOUND: &'static str = "https://spikard.dev/errors/not-found";
75
76    /// Standard type URI for method not allowed (405)
77    pub const TYPE_METHOD_NOT_ALLOWED: &'static str = "https://spikard.dev/errors/method-not-allowed";
78
79    /// Standard type URI for internal server error (500)
80    pub const TYPE_INTERNAL_SERVER_ERROR: &'static str = "https://spikard.dev/errors/internal-server-error";
81
82    /// Standard type URI for bad request (400)
83    pub const TYPE_BAD_REQUEST: &'static str = "https://spikard.dev/errors/bad-request";
84
85    /// Create a new ProblemDetails with required fields
86    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    /// Set the detail field
98    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
99        self.detail = Some(detail.into());
100        self
101    }
102
103    /// Set the instance field
104    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
105        self.instance = Some(instance.into());
106        self
107    }
108
109    /// Add an extension field
110    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    /// Add all extensions from a JSON object
116    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    /// Create a validation error Problem Details from ValidationError
126    ///
127    /// This converts the FastAPI-style validation errors to RFC 9457 format:
128    /// - `type`: "https://spikard.dev/errors/validation-error"
129    /// - `title`: "Request Validation Failed"
130    /// - `status`: 422
131    /// - `detail`: Summary of error count
132    /// - `errors`: Array of validation error details (as extension field)
133    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    /// Create a not found error
153    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    /// Create a method not allowed error
158    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    /// Create an internal server error
168    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    /// Create an internal server error with debug information
178    ///
179    /// Includes exception details, traceback, and request data for debugging.
180    /// Only use in development/debug mode.
181    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    /// Create a bad request error
199    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    /// Get the HTTP status code
204    pub fn status_code(&self) -> StatusCode {
205        StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
206    }
207
208    /// Serialize to JSON string
209    pub fn to_json(&self) -> Result<String, serde_json::Error> {
210        serde_json::to_string(self)
211    }
212
213    /// Serialize to pretty JSON string
214    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
215        serde_json::to_string_pretty(self)
216    }
217}
218
219/// Content-Type for RFC 9457 Problem Details
220pub 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_content_type_constant() {
318        assert_eq!(CONTENT_TYPE_PROBLEM_JSON, "application/problem+json; charset=utf-8");
319    }
320}