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    /// Create a validation error Problem Details from ValidationError
116    ///
117    /// This converts the FastAPI-style validation errors to RFC 9457 format:
118    /// - `type`: "https://spikard.dev/errors/validation-error"
119    /// - `title`: "Request Validation Failed"
120    /// - `status`: 422
121    /// - `detail`: Summary of error count
122    /// - `errors`: Array of validation error details (as extension field)
123    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    /// Create a not found error
143    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    /// Create a method not allowed error
148    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    /// Create an internal server error
158    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    /// Create an internal server error with debug information
168    ///
169    /// Includes exception details, traceback, and request data for debugging.
170    /// Only use in development/debug mode.
171    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    /// Create a bad request error
189    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    /// Get the HTTP status code
194    pub fn status_code(&self) -> StatusCode {
195        StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
196    }
197
198    /// Serialize to JSON string
199    pub fn to_json(&self) -> Result<String, serde_json::Error> {
200        serde_json::to_string(self)
201    }
202
203    /// Serialize to pretty JSON string
204    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
205        serde_json::to_string_pretty(self)
206    }
207}
208
209/// Content-Type for RFC 9457 Problem Details
210pub 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}