Skip to main content

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