Skip to main content

fraiseql_core/validation/
error_responses.rs

1//! GraphQL-compliant error response formatting for validation errors.
2//!
3//! This module provides utilities for converting validation errors into
4//! GraphQL error responses with proper structure and context.
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{FraiseQLError, ValidationFieldError};
9
10/// A GraphQL error with extensions (validation details).
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct GraphQLValidationError {
13    /// Error message shown to client
14    pub message: String,
15
16    /// Path to the field with error (e.g., "createUser.input.email")
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub path: Option<Vec<String>>,
19
20    /// Extensions with validation details
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub extensions: Option<ValidationErrorExtensions>,
23}
24
25/// Extensions carrying validation-specific error details.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ValidationErrorExtensions {
28    /// GraphQL error code
29    pub code: String,
30
31    /// Human-readable rule type that failed
32    pub rule_type: String,
33
34    /// Field path as dot-separated string
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub field_path: Option<String>,
37
38    /// Additional context (e.g., why pattern didn't match)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub context: Option<serde_json::Value>,
41}
42
43/// Collection of GraphQL validation errors.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GraphQLValidationResponse {
46    /// List of validation errors
47    pub errors: Vec<GraphQLValidationError>,
48
49    /// Total error count
50    pub error_count: usize,
51}
52
53impl GraphQLValidationResponse {
54    /// Create a new empty error response.
55    pub const fn new() -> Self {
56        Self {
57            errors:      Vec::new(),
58            error_count: 0,
59        }
60    }
61
62    /// Add a validation field error to the response.
63    pub fn add_field_error(
64        &mut self,
65        field_error: ValidationFieldError,
66        context: Option<serde_json::Value>,
67    ) {
68        let path = Self::parse_path(&field_error.field);
69        let extensions = ValidationErrorExtensions {
70            code: "VALIDATION_FAILED".to_string(),
71            rule_type: field_error.rule_type,
72            field_path: Some(field_error.field.clone()),
73            context,
74        };
75
76        self.errors.push(GraphQLValidationError {
77            message:    format!("Validation failed: {}", field_error.message),
78            path:       Some(path),
79            extensions: Some(extensions),
80        });
81
82        self.error_count += 1;
83    }
84
85    /// Add multiple validation errors at once.
86    pub fn add_errors(&mut self, errors: Vec<ValidationFieldError>) {
87        for error in errors {
88            self.add_field_error(error, None);
89        }
90    }
91
92    /// Convert from `FraiseQLError` to validation response.
93    pub fn from_error(error: &FraiseQLError) -> Option<Self> {
94        if let FraiseQLError::Validation { message, path } = error {
95            let mut response = Self::new();
96            response.errors.push(GraphQLValidationError {
97                message:    message.clone(),
98                path:       path.as_ref().map(|p| Self::parse_path(p)),
99                extensions: Some(ValidationErrorExtensions {
100                    code:       "VALIDATION_FAILED".to_string(),
101                    rule_type:  "unknown".to_string(),
102                    field_path: path.clone(),
103                    context:    None,
104                }),
105            });
106            response.error_count = 1;
107            Some(response)
108        } else {
109            None
110        }
111    }
112
113    /// Parse a dot-separated field path into path segments.
114    fn parse_path(path: &str) -> Vec<String> {
115        path.split('.').map(|s| s.to_string()).collect()
116    }
117
118    /// Check if response has any errors.
119    pub const fn has_errors(&self) -> bool {
120        !self.errors.is_empty()
121    }
122
123    /// Serialize to JSON suitable for GraphQL response.
124    pub fn to_graphql_errors(&self) -> serde_json::Value {
125        serde_json::json!({
126            "errors": self.errors,
127            "error_count": self.error_count
128        })
129    }
130}
131
132impl Default for GraphQLValidationResponse {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
141
142    use super::*;
143
144    #[test]
145    fn test_create_empty_response() {
146        let response = GraphQLValidationResponse::new();
147        assert!(!response.has_errors());
148        assert_eq!(response.error_count, 0);
149    }
150
151    #[test]
152    fn test_add_single_error() {
153        let mut response = GraphQLValidationResponse::new();
154        let field_error = ValidationFieldError::new("email", "pattern", "Invalid email format");
155        response.add_field_error(field_error, None);
156
157        assert!(response.has_errors());
158        assert_eq!(response.error_count, 1);
159        assert_eq!(response.errors[0].extensions.as_ref().unwrap().rule_type, "pattern");
160    }
161
162    #[test]
163    fn test_add_multiple_errors() {
164        let mut response = GraphQLValidationResponse::new();
165        let errors = vec![
166            ValidationFieldError::new("email", "pattern", "Invalid email"),
167            ValidationFieldError::new("phone", "pattern", "Invalid phone"),
168        ];
169        response.add_errors(errors);
170
171        assert_eq!(response.error_count, 2);
172    }
173
174    #[test]
175    fn test_path_parsing() {
176        let path = GraphQLValidationResponse::parse_path("user.email");
177        assert_eq!(path, vec!["user".to_string(), "email".to_string()]);
178
179        let path = GraphQLValidationResponse::parse_path("address.zipcode");
180        assert_eq!(path, vec!["address".to_string(), "zipcode".to_string()]);
181    }
182
183    #[test]
184    fn test_json_serialization() {
185        let mut response = GraphQLValidationResponse::new();
186        let field_error = ValidationFieldError::new("field1", "rule1", "Error message");
187        response.add_field_error(field_error, Some(serde_json::json!({"detail": "extra"})));
188
189        let json = response.to_graphql_errors();
190        assert!(json["error_count"].is_number());
191        assert!(json["errors"].is_array());
192    }
193
194    #[test]
195    fn test_from_fraiseql_error() {
196        let error = FraiseQLError::Validation {
197            message: "Validation failed".to_string(),
198            path:    Some("user.email".to_string()),
199        };
200
201        let response = GraphQLValidationResponse::from_error(&error);
202        assert!(response.is_some());
203        let response = response.unwrap();
204        assert_eq!(response.error_count, 1);
205    }
206
207    #[test]
208    fn test_context_inclusion() {
209        let mut response = GraphQLValidationResponse::new();
210        let field_error = ValidationFieldError::new("password", "length", "Too short");
211        let context = serde_json::json!({"minimum_length": 12, "provided_length": 8});
212        response.add_field_error(field_error, Some(context));
213
214        assert!(response.errors[0].extensions.as_ref().unwrap().context.is_some());
215    }
216}