rustapi_validate/v2/
error.rs

1//! Error types for the v2 validation engine.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fmt;
6
7/// Error from a single validation rule.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct RuleError {
10    /// The validation rule code (e.g., "email", "length", "range")
11    pub code: String,
12    /// Human-readable error message
13    pub message: String,
14    /// Optional parameters for message interpolation
15    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
16    pub params: HashMap<String, serde_json::Value>,
17}
18
19impl RuleError {
20    /// Create a new rule error.
21    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
22        Self {
23            code: code.into(),
24            message: message.into(),
25            params: HashMap::new(),
26        }
27    }
28
29    /// Create a rule error with parameters.
30    pub fn with_params(
31        code: impl Into<String>,
32        message: impl Into<String>,
33        params: HashMap<String, serde_json::Value>,
34    ) -> Self {
35        Self {
36            code: code.into(),
37            message: message.into(),
38            params,
39        }
40    }
41
42    /// Add a parameter to the error.
43    pub fn param(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
44        if let Ok(v) = serde_json::to_value(value) {
45            self.params.insert(key.into(), v);
46        }
47        self
48    }
49
50    /// Interpolate parameters into the message, optionally localized.
51    ///
52    /// If a locale is provided, attempts to translate the message key.
53    /// Replaces `{param_name}` placeholders with actual values.
54    pub fn interpolate_with_locale(&self, locale: Option<&str>) -> String {
55        let msg = crate::v2::i18n::translate(&self.message, locale);
56        let mut result = msg;
57
58        for (key, value) in &self.params {
59            let placeholder = format!("{{{}}}", key);
60            let p_placeholder = format!("%{{{}}}", key); // Support ruby style %{param} often used in i18n
61
62            let replacement = match value {
63                serde_json::Value::String(s) => s.clone(),
64                serde_json::Value::Number(n) => n.to_string(),
65                serde_json::Value::Bool(b) => b.to_string(),
66                _ => value.to_string(),
67            };
68            // Replace both {param} and %{param}
69            result = result.replace(&p_placeholder, &replacement);
70            result = result.replace(&placeholder, &replacement);
71        }
72        result
73    }
74
75    /// Interpolate parameters into the message (uses default locale).
76    pub fn interpolate_message(&self) -> String {
77        self.interpolate_with_locale(None)
78    }
79}
80
81impl fmt::Display for RuleError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(f, "[{}] {}", self.code, self.interpolate_message())
84    }
85}
86
87impl std::error::Error for RuleError {}
88
89/// Collection of validation errors for multiple fields.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct ValidationErrors {
92    /// Map of field name to list of errors for that field
93    #[serde(flatten)]
94    pub fields: HashMap<String, Vec<RuleError>>,
95}
96
97impl ValidationErrors {
98    /// Create an empty validation errors collection.
99    pub fn new() -> Self {
100        Self {
101            fields: HashMap::new(),
102        }
103    }
104
105    /// Add an error for a field.
106    pub fn add(&mut self, field: impl Into<String>, error: RuleError) {
107        self.fields.entry(field.into()).or_default().push(error);
108    }
109
110    /// Add multiple errors for a field.
111    pub fn add_all(&mut self, field: impl Into<String>, errors: Vec<RuleError>) {
112        let field = field.into();
113        for error in errors {
114            self.add(field.clone(), error);
115        }
116    }
117
118    /// Merge another ValidationErrors into this one.
119    pub fn merge(&mut self, other: ValidationErrors) {
120        for (field, errors) in other.fields {
121            self.add_all(field, errors);
122        }
123    }
124
125    /// Check if there are any errors.
126    pub fn is_empty(&self) -> bool {
127        self.fields.is_empty()
128    }
129
130    /// Get the total number of errors.
131    pub fn len(&self) -> usize {
132        self.fields.values().map(|v| v.len()).sum()
133    }
134
135    /// Get errors for a specific field.
136    pub fn get(&self, field: &str) -> Option<&Vec<RuleError>> {
137        self.fields.get(field)
138    }
139
140    /// Convert to Result - Ok if no errors, Err otherwise.
141    pub fn into_result(self) -> Result<(), Self> {
142        if self.is_empty() {
143            Ok(())
144        } else {
145            Err(self)
146        }
147    }
148
149    /// Get all field names with errors.
150    pub fn field_names(&self) -> Vec<&str> {
151        self.fields.keys().map(|s| s.as_str()).collect()
152    }
153
154    /// Convert to the standard RustAPI error format with localization.
155    pub fn to_api_error_with_locale(&self, locale: Option<&str>) -> ApiValidationError {
156        let fields: Vec<FieldErrorResponse> = self
157            .fields
158            .iter()
159            .flat_map(|(field, errors)| {
160                errors.iter().map(move |e| FieldErrorResponse {
161                    field: field.clone(),
162                    code: e.code.clone(),
163                    message: e.interpolate_with_locale(locale),
164                    params: if e.params.is_empty() {
165                        None
166                    } else {
167                        Some(e.params.clone())
168                    },
169                })
170            })
171            .collect();
172
173        ApiValidationError {
174            error: ErrorBody {
175                error_type: "validation_error".to_string(),
176                message: "Validation failed".to_string(),
177                fields,
178            },
179        }
180    }
181
182    /// Convert to the standard RustAPI error format.
183    pub fn to_api_error(&self) -> ApiValidationError {
184        self.to_api_error_with_locale(None)
185    }
186}
187
188impl fmt::Display for ValidationErrors {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        write!(f, "Validation failed: {} error(s)", self.len())
191    }
192}
193
194impl std::error::Error for ValidationErrors {}
195
196/// API response format for validation errors.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct ApiValidationError {
199    pub error: ErrorBody,
200}
201
202/// Error body in API response.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct ErrorBody {
205    #[serde(rename = "type")]
206    pub error_type: String,
207    pub message: String,
208    pub fields: Vec<FieldErrorResponse>,
209}
210
211/// Single field error in API response.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct FieldErrorResponse {
214    pub field: String,
215    pub code: String,
216    pub message: String,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub params: Option<HashMap<String, serde_json::Value>>,
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn rule_error_creation() {
227        let error = RuleError::new("email", "Invalid email format");
228        assert_eq!(error.code, "email");
229        assert_eq!(error.message, "Invalid email format");
230        assert!(error.params.is_empty());
231    }
232
233    #[test]
234    fn rule_error_with_params() {
235        let error = RuleError::new("length", "Must be between {min} and {max} characters")
236            .param("min", 3)
237            .param("max", 50);
238
239        assert_eq!(
240            error.interpolate_message(),
241            "Must be between 3 and 50 characters"
242        );
243    }
244
245    #[test]
246    fn validation_errors_add_and_get() {
247        let mut errors = ValidationErrors::new();
248        errors.add("email", RuleError::new("email", "Invalid email"));
249        errors.add("email", RuleError::new("required", "Email is required"));
250        errors.add("age", RuleError::new("range", "Age out of range"));
251
252        assert_eq!(errors.len(), 3);
253        assert_eq!(errors.get("email").unwrap().len(), 2);
254        assert_eq!(errors.get("age").unwrap().len(), 1);
255    }
256
257    #[test]
258    fn validation_errors_into_result() {
259        let errors = ValidationErrors::new();
260        assert!(errors.into_result().is_ok());
261
262        let mut errors = ValidationErrors::new();
263        errors.add("field", RuleError::new("code", "message"));
264        assert!(errors.into_result().is_err());
265    }
266
267    #[test]
268    fn validation_errors_to_api_error() {
269        let mut errors = ValidationErrors::new();
270        errors.add("email", RuleError::new("email", "Invalid email format"));
271
272        let api_error = errors.to_api_error();
273        assert_eq!(api_error.error.error_type, "validation_error");
274        assert_eq!(api_error.error.fields.len(), 1);
275        assert_eq!(api_error.error.fields[0].field, "email");
276    }
277
278    #[test]
279    fn validation_errors_merge() {
280        let mut errors1 = ValidationErrors::new();
281        errors1.add("email", RuleError::new("email", "Invalid"));
282
283        let mut errors2 = ValidationErrors::new();
284        errors2.add("age", RuleError::new("range", "Out of range"));
285
286        errors1.merge(errors2);
287        assert_eq!(errors1.len(), 2);
288        assert!(errors1.get("email").is_some());
289        assert!(errors1.get("age").is_some());
290    }
291}