domainstack_envelope/
lib.rs

1//! # domainstack-envelope
2//!
3//! Convert domainstack validation errors into structured HTTP error responses.
4//!
5//! This crate provides the `IntoEnvelopeError` trait to convert `ValidationError` into
6//! `error_envelope::Error`, producing structured JSON error responses with field-level details.
7//!
8//! ## What it provides
9//!
10//! - **`IntoEnvelopeError`** trait - Convert `ValidationError` to `error_envelope::Error`
11//! - **Field-level error mapping** - Preserves error paths like `rooms[0].adults`, `guest.email`
12//! - **Structured error format** - Consistent HTTP error response format with field-level details
13//! - **Metadata preservation** - Includes validation metadata (min, max, etc.) in responses
14//!
15//! ## Example
16//!
17//! ```rust
18//! use domainstack::prelude::*;
19//! use domainstack_envelope::IntoEnvelopeError;
20//!
21//! let mut err = domainstack::ValidationError::new();
22//! err.push("email", "invalid_email", "Invalid email format");
23//! err.push("age", "out_of_range", "Must be between 18 and 120");
24//!
25//! let envelope = err.into_envelope_error();
26//!
27//! // Produces structured error response:
28//! // {
29//! //   "code": "VALIDATION",
30//! //   "status": 400,
31//! //   "message": "Validation failed with 2 errors",
32//! //   "details": {
33//! //     "fields": {
34//! //       "email": [{"code": "invalid_email", "message": "Invalid email format"}],
35//! //       "age": [{"code": "out_of_range", "message": "Must be between 18 and 120"}]
36//! //     }
37//! //   }
38//! // }
39//! ```
40//!
41//! ## Integration with Web Frameworks
42//!
43//! Use with framework adapters for automatic error response handling:
44//!
45//! - **`domainstack-axum`** - Axum integration
46//! - **`domainstack-actix`** - Actix-web integration
47//! - **`domainstack-rocket`** - Rocket integration
48
49use domainstack::{ValidationError, Violation};
50use error_envelope::Error;
51
52pub trait IntoEnvelopeError {
53    fn into_envelope_error(self) -> Error;
54}
55
56impl IntoEnvelopeError for ValidationError {
57    fn into_envelope_error(self) -> Error {
58        let violation_count = self.violations.len();
59
60        let message = if violation_count == 1 {
61            format!("Validation failed: {}", self.violations[0].message)
62        } else {
63            format!("Validation failed with {} errors", violation_count)
64        };
65
66        let details = create_field_details(&self);
67
68        Error::validation(message)
69            .with_details(details)
70            .with_retryable(false)
71    }
72}
73
74fn create_field_details(validation_error: &ValidationError) -> serde_json::Value {
75    let field_map = validation_error.field_violations_map();
76
77    let mut fields = serde_json::Map::new();
78
79    for (path, violations) in field_map {
80        let violations_json: Vec<serde_json::Value> =
81            violations.into_iter().map(violation_to_json).collect();
82
83        fields.insert(path, serde_json::Value::Array(violations_json));
84    }
85
86    serde_json::json!({
87        "fields": fields
88    })
89}
90
91fn violation_to_json(violation: &Violation) -> serde_json::Value {
92    let mut obj = serde_json::Map::new();
93    obj.insert(
94        "code".to_string(),
95        serde_json::Value::String(violation.code.to_string()),
96    );
97    obj.insert(
98        "message".to_string(),
99        serde_json::Value::String(violation.message.clone()),
100    );
101
102    if !violation.meta.is_empty() {
103        let mut meta = serde_json::Map::new();
104        for (key, value) in violation.meta.iter() {
105            meta.insert(
106                key.to_string(),
107                serde_json::Value::String(value.to_string()),
108            );
109        }
110        obj.insert("meta".to_string(), serde_json::Value::Object(meta));
111    }
112
113    serde_json::Value::Object(obj)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use domainstack::{Path, ValidationError};
120
121    #[test]
122    fn test_single_violation_conversion() {
123        let mut err = ValidationError::new();
124        err.push("email", "invalid_email", "Invalid email format");
125
126        let envelope = err.into_envelope_error();
127
128        assert_eq!(envelope.status, 400);
129        assert_eq!(envelope.message, "Validation failed: Invalid email format");
130        assert!(!envelope.retryable);
131
132        let details = envelope.details.expect("Should have details");
133        let fields = details["fields"]
134            .as_object()
135            .expect("Should have fields object");
136
137        assert!(fields.contains_key("email"));
138        let email_violations = fields["email"].as_array().expect("Should be array");
139        assert_eq!(email_violations.len(), 1);
140        assert_eq!(email_violations[0]["code"], "invalid_email");
141        assert_eq!(email_violations[0]["message"], "Invalid email format");
142    }
143
144    #[test]
145    fn test_multiple_violations_conversion() {
146        let mut err = ValidationError::new();
147        err.push("name", "min_length", "Must be at least 1 characters");
148        err.push("age", "out_of_range", "Must be between 18 and 120");
149
150        let envelope = err.into_envelope_error();
151
152        assert_eq!(envelope.status, 400);
153        assert_eq!(envelope.message, "Validation failed with 2 errors");
154
155        let details = envelope.details.expect("Should have details");
156        let fields = details["fields"]
157            .as_object()
158            .expect("Should have fields object");
159
160        assert_eq!(fields.len(), 2);
161        assert!(fields.contains_key("name"));
162        assert!(fields.contains_key("age"));
163    }
164
165    #[test]
166    fn test_nested_path_preservation() {
167        let mut err = ValidationError::new();
168        err.push(
169            Path::root().field("guest").field("email"),
170            "invalid_email",
171            "Invalid email format",
172        );
173
174        let envelope = err.into_envelope_error();
175
176        let details = envelope.details.expect("Should have details");
177        let fields = details["fields"]
178            .as_object()
179            .expect("Should have fields object");
180
181        assert!(fields.contains_key("guest.email"));
182    }
183
184    #[test]
185    fn test_collection_path_with_index() {
186        let mut err = ValidationError::new();
187        err.push(
188            Path::root().field("rooms").index(0).field("adults"),
189            "out_of_range",
190            "Must be between 1 and 4",
191        );
192        err.push(
193            Path::root().field("rooms").index(1).field("children"),
194            "out_of_range",
195            "Must be between 0 and 3",
196        );
197
198        let envelope = err.into_envelope_error();
199
200        let details = envelope.details.expect("Should have details");
201        let fields = details["fields"]
202            .as_object()
203            .expect("Should have fields object");
204
205        assert!(fields.contains_key("rooms[0].adults"));
206        assert!(fields.contains_key("rooms[1].children"));
207    }
208
209    #[test]
210    fn test_meta_field_inclusion() {
211        let mut err = ValidationError::new();
212        let mut violation = domainstack::Violation {
213            path: Path::from("age"),
214            code: "out_of_range",
215            message: "Must be between 18 and 120".to_string(),
216            meta: domainstack::Meta::new(),
217        };
218        violation.meta.insert("min", 18);
219        violation.meta.insert("max", 120);
220        err.violations.push(violation);
221
222        let envelope = err.into_envelope_error();
223
224        let details = envelope.details.expect("Should have details");
225        let fields = details["fields"]
226            .as_object()
227            .expect("Should have fields object");
228        let age_violations = fields["age"].as_array().expect("Should be array");
229
230        assert_eq!(age_violations[0]["meta"]["min"], "18");
231        assert_eq!(age_violations[0]["meta"]["max"], "120");
232    }
233
234    #[test]
235    fn test_multiple_violations_same_field() {
236        let mut err = ValidationError::new();
237        err.push("password", "no_uppercase", "Must contain uppercase letter");
238        err.push("password", "no_digit", "Must contain digit");
239
240        let envelope = err.into_envelope_error();
241
242        let details = envelope.details.expect("Should have details");
243        let fields = details["fields"]
244            .as_object()
245            .expect("Should have fields object");
246
247        assert_eq!(fields.len(), 1);
248        let password_violations = fields["password"].as_array().expect("Should be array");
249        assert_eq!(password_violations.len(), 2);
250    }
251
252    #[test]
253    fn test_deeply_nested_path() {
254        let mut err = ValidationError::new();
255        err.push(
256            Path::root()
257                .field("order")
258                .field("items")
259                .index(0)
260                .field("product")
261                .field("variants")
262                .index(2)
263                .field("sku"),
264            "invalid_sku",
265            "SKU format is invalid",
266        );
267
268        let envelope = err.into_envelope_error();
269
270        let details = envelope.details.expect("Should have details");
271        let fields = details["fields"]
272            .as_object()
273            .expect("Should have fields object");
274
275        assert!(fields.contains_key("order.items[0].product.variants[2].sku"));
276    }
277
278    #[test]
279    fn test_empty_message_string() {
280        let mut err = ValidationError::new();
281        err.push("field", "error_code", "");
282
283        let envelope = err.into_envelope_error();
284
285        let details = envelope.details.expect("Should have details");
286        let violations = details["fields"]["field"]
287            .as_array()
288            .expect("Should be array");
289        assert_eq!(violations[0]["message"], "");
290    }
291
292    #[test]
293    fn test_special_characters_in_message() {
294        let mut err = ValidationError::new();
295        err.push(
296            "field",
297            "error",
298            r#"Message with "quotes", 'apostrophes', and \backslash"#,
299        );
300
301        let envelope = err.into_envelope_error();
302
303        let details = envelope.details.expect("Should have details");
304        let violations = details["fields"]["field"]
305            .as_array()
306            .expect("Should be array");
307        assert_eq!(
308            violations[0]["message"],
309            r#"Message with "quotes", 'apostrophes', and \backslash"#
310        );
311    }
312
313    #[test]
314    fn test_meta_with_special_characters() {
315        let mut err = ValidationError::new();
316        let mut violation = domainstack::Violation {
317            path: Path::from("field"),
318            code: "error",
319            message: "Error".to_string(),
320            meta: domainstack::Meta::new(),
321        };
322        violation.meta.insert("key_with:colon", "value");
323        violation.meta.insert("pattern", r"^[\w]+$");
324        err.violations.push(violation);
325
326        let envelope = err.into_envelope_error();
327
328        let details = envelope.details.expect("Should have details");
329        let violations = details["fields"]["field"]
330            .as_array()
331            .expect("Should be array");
332        let meta = violations[0]["meta"].as_object().expect("Should have meta");
333        assert_eq!(meta["key_with:colon"], "value");
334        assert_eq!(meta["pattern"], r"^[\w]+$");
335    }
336
337    #[test]
338    fn test_three_violations_message_format() {
339        let mut err = ValidationError::new();
340        err.push("a", "err", "Error A");
341        err.push("b", "err", "Error B");
342        err.push("c", "err", "Error C");
343
344        let envelope = err.into_envelope_error();
345
346        assert_eq!(envelope.message, "Validation failed with 3 errors");
347    }
348
349    #[test]
350    fn test_empty_meta_not_included() {
351        let mut err = ValidationError::new();
352        err.push("field", "error_code", "Error message");
353
354        let envelope = err.into_envelope_error();
355
356        let details = envelope.details.expect("Should have details");
357        let violations = details["fields"]["field"]
358            .as_array()
359            .expect("Should be array");
360        // Empty meta should not be present in output
361        assert!(violations[0].get("meta").is_none());
362    }
363
364    #[test]
365    fn test_large_number_of_violations() {
366        let mut err = ValidationError::new();
367        for i in 0..100 {
368            err.push(format!("field{}", i), "error", format!("Error {}", i));
369        }
370
371        let envelope = err.into_envelope_error();
372
373        assert_eq!(envelope.message, "Validation failed with 100 errors");
374        let details = envelope.details.expect("Should have details");
375        let fields = details["fields"]
376            .as_object()
377            .expect("Should have fields object");
378        assert_eq!(fields.len(), 100);
379    }
380
381    #[test]
382    fn test_root_path_violation() {
383        let mut err = ValidationError::new();
384        let violation = domainstack::Violation {
385            path: Path::root(),
386            code: "invalid_object",
387            message: "Object is invalid".to_string(),
388            meta: domainstack::Meta::new(),
389        };
390        err.violations.push(violation);
391
392        let envelope = err.into_envelope_error();
393
394        let details = envelope.details.expect("Should have details");
395        let fields = details["fields"]
396            .as_object()
397            .expect("Should have fields object");
398        // Root path should be represented as empty string
399        assert!(fields.contains_key(""));
400    }
401}