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