Skip to main content

specmock_core/
error.rs

1//! Error and validation issue models.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// Content-Type for RFC 7807 responses.
7pub const PROBLEM_JSON_CONTENT_TYPE: &str = "application/problem+json";
8
9/// Standard validation issue item with JSON pointer locations.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct ValidationIssue {
12    /// JSON pointer into instance payload.
13    pub instance_pointer: String,
14    /// JSON pointer into schema.
15    pub schema_pointer: String,
16    /// Best-effort keyword.
17    pub keyword: String,
18    /// Human-readable description.
19    pub message: String,
20}
21
22/// RFC 7807 Problem Details response.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ProblemDetails {
25    /// URI reference identifying the problem type.
26    #[serde(rename = "type")]
27    pub problem_type: String,
28    /// Short summary.
29    pub title: String,
30    /// HTTP status code.
31    pub status: u16,
32    /// Human-readable explanation.
33    pub detail: String,
34    /// URI of the request that caused the error.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub instance: Option<String>,
37    /// Detailed validation errors.
38    #[serde(skip_serializing_if = "Vec::is_empty", default)]
39    pub errors: Vec<ValidationIssue>,
40}
41
42impl ProblemDetails {
43    /// Create a validation error response wrapping a list of [`ValidationIssue`]s.
44    #[must_use]
45    pub fn validation_error(status: u16, issues: Vec<ValidationIssue>) -> Self {
46        Self {
47            problem_type: "about:blank".to_owned(),
48            title: title_for_status(status).to_owned(),
49            status,
50            detail: "Request validation failed".to_owned(),
51            instance: None,
52            errors: issues,
53        }
54    }
55
56    /// Create a 404 Not Found response.
57    #[must_use]
58    pub fn not_found(detail: &str) -> Self {
59        Self {
60            problem_type: "about:blank".to_owned(),
61            title: "Not Found".to_owned(),
62            status: 404,
63            detail: detail.to_owned(),
64            instance: None,
65            errors: Vec::new(),
66        }
67    }
68
69    /// Create a 415 Unsupported Media Type response.
70    #[must_use]
71    pub fn unsupported_media_type(detail: &str) -> Self {
72        Self {
73            problem_type: "about:blank".to_owned(),
74            title: "Unsupported Media Type".to_owned(),
75            status: 415,
76            detail: detail.to_owned(),
77            instance: None,
78            errors: Vec::new(),
79        }
80    }
81
82    /// Create a 413 Payload Too Large response.
83    #[must_use]
84    pub fn payload_too_large(detail: &str) -> Self {
85        Self {
86            problem_type: "about:blank".to_owned(),
87            title: "Payload Too Large".to_owned(),
88            status: 413,
89            detail: detail.to_owned(),
90            instance: None,
91            errors: Vec::new(),
92        }
93    }
94}
95
96/// Map common HTTP status codes to their standard reason phrase.
97const fn title_for_status(status: u16) -> &'static str {
98    match status {
99        400 => "Bad Request",
100        401 => "Unauthorized",
101        403 => "Forbidden",
102        404 => "Not Found",
103        405 => "Method Not Allowed",
104        406 => "Not Acceptable",
105        409 => "Conflict",
106        413 => "Payload Too Large",
107        415 => "Unsupported Media Type",
108        422 => "Unprocessable Entity",
109        429 => "Too Many Requests",
110        500 => "Internal Server Error",
111        502 => "Bad Gateway",
112        503 => "Service Unavailable",
113        _ => "Unknown Error",
114    }
115}
116
117/// Core error type.
118#[derive(Debug, Error)]
119pub enum SpecMockCoreError {
120    /// Invalid schema document.
121    #[error("schema compilation failed: {0}")]
122    Schema(String),
123    /// Data generation failed.
124    #[error("data generation failed: {0}")]
125    Faker(String),
126    /// `$ref` resolution failed.
127    #[error("$ref resolution failed: {0}")]
128    Ref(String),
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn validation_error_serialization_roundtrip() {
137        let issues = vec![ValidationIssue {
138            instance_pointer: "/name".to_owned(),
139            schema_pointer: "/properties/name/minLength".to_owned(),
140            keyword: "minLength".to_owned(),
141            message: "must be at least 1 character".to_owned(),
142        }];
143
144        let problem = ProblemDetails::validation_error(400, issues);
145        let json = serde_json::to_string(&problem).expect("serialize");
146        let roundtrip: ProblemDetails = serde_json::from_str(&json).expect("deserialize");
147
148        assert_eq!(roundtrip.status, 400);
149        assert_eq!(roundtrip.title, "Bad Request");
150        assert_eq!(roundtrip.problem_type, "about:blank");
151        assert_eq!(roundtrip.detail, "Request validation failed");
152        assert!(roundtrip.instance.is_none());
153        assert_eq!(roundtrip.errors.len(), 1);
154        assert_eq!(roundtrip.errors[0].instance_pointer, "/name");
155    }
156
157    #[test]
158    fn validation_error_json_shape() {
159        let problem = ProblemDetails::validation_error(422, vec![]);
160        let value = serde_json::to_value(&problem).expect("to_value");
161
162        // `type` field must be present (serde rename)
163        assert_eq!(value["type"], "about:blank");
164        assert_eq!(value["status"], 422);
165        assert_eq!(value["title"], "Unprocessable Entity");
166        // empty errors array must be omitted
167        assert!(value.get("errors").is_none());
168    }
169
170    #[test]
171    fn not_found_has_correct_fields() {
172        let problem = ProblemDetails::not_found("no such path: /pets/99");
173        assert_eq!(problem.status, 404);
174        assert_eq!(problem.title, "Not Found");
175        assert_eq!(problem.detail, "no such path: /pets/99");
176        assert!(problem.errors.is_empty());
177    }
178
179    #[test]
180    fn unsupported_media_type_has_correct_fields() {
181        let problem = ProblemDetails::unsupported_media_type("expected application/json");
182        assert_eq!(problem.status, 415);
183        assert_eq!(problem.title, "Unsupported Media Type");
184    }
185
186    #[test]
187    fn payload_too_large_has_correct_fields() {
188        let problem = ProblemDetails::payload_too_large("body exceeds 1 MB");
189        assert_eq!(problem.status, 413);
190        assert_eq!(problem.title, "Payload Too Large");
191    }
192
193    #[test]
194    fn deserialize_without_optional_fields() {
195        let json = r#"{"type":"about:blank","title":"Not Found","status":404,"detail":"gone"}"#;
196        let problem: ProblemDetails = serde_json::from_str(json).expect("deserialize");
197        assert_eq!(problem.status, 404);
198        assert!(problem.instance.is_none());
199        assert!(problem.errors.is_empty());
200    }
201
202    #[test]
203    fn content_type_constant() {
204        assert_eq!(PROBLEM_JSON_CONTENT_TYPE, "application/problem+json");
205    }
206}