Skip to main content

fraiseql_core/validation/
scalar_validator.rs

1//! Validation engine for custom GraphQL scalars.
2//!
3//! Provides utilities to validate custom scalar values in different contexts.
4
5use serde_json::Value;
6
7use super::custom_scalar::CustomScalar;
8use crate::error::{FraiseQLError, Result};
9
10/// Validation context for custom scalar operations.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum ValidationContext {
14    /// Serialize a database value to GraphQL response.
15    Serialize,
16
17    /// Parse a variable value from GraphQL operation.
18    ParseValue,
19
20    /// Parse a literal value from GraphQL query string.
21    ParseLiteral,
22}
23
24impl ValidationContext {
25    /// Get the string representation of this context.
26    pub const fn as_str(&self) -> &'static str {
27        match self {
28            Self::Serialize => "serialize",
29            Self::ParseValue => "parseValue",
30            Self::ParseLiteral => "parseLiteral",
31        }
32    }
33}
34
35/// Error returned when custom scalar validation fails.
36#[derive(Debug, Clone)]
37pub struct ScalarValidationError {
38    /// Name of the scalar that failed validation.
39    pub scalar_name: String,
40
41    /// Context in which validation occurred.
42    pub context: String,
43
44    /// Underlying error message.
45    pub message: String,
46}
47
48impl ScalarValidationError {
49    /// Create a new scalar validation error.
50    pub fn new(
51        scalar_name: impl Into<String>,
52        context: impl Into<String>,
53        message: impl Into<String>,
54    ) -> Self {
55        Self {
56            scalar_name: scalar_name.into(),
57            context:     context.into(),
58            message:     message.into(),
59        }
60    }
61
62    /// Convert to `FraiseQLError`.
63    pub fn into_fraiseql_error(self) -> FraiseQLError {
64        FraiseQLError::validation(self.to_string())
65    }
66}
67
68impl std::fmt::Display for ScalarValidationError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(
71            f,
72            "Scalar \"{}\" validation failed in {}: {}",
73            self.scalar_name, self.context, self.message
74        )
75    }
76}
77
78impl std::error::Error for ScalarValidationError {}
79
80/// Validate a custom scalar value in a given context.
81///
82/// # Arguments
83///
84/// * `scalar` - The custom scalar implementation
85/// * `value` - The value to validate
86/// * `context` - The validation context
87///
88/// # Errors
89///
90/// Returns `ScalarValidationError` if validation fails.
91///
92/// # Example
93///
94/// ```
95/// use fraiseql_core::validation::{validate_custom_scalar, ValidationContext, CustomScalar};
96/// use fraiseql_core::error::Result;
97/// use serde_json::{Value, json};
98///
99/// #[derive(Debug)]
100/// struct Email;
101/// impl CustomScalar for Email {
102///     fn name(&self) -> &str { "Email" }
103///     fn serialize(&self, value: &Value) -> Result<Value> { Ok(value.clone()) }
104///     fn parse_value(&self, value: &Value) -> Result<Value> {
105///         let str_val = value.as_str().unwrap();
106///         if !str_val.contains('@') {
107///             return Err(fraiseql_core::error::FraiseQLError::validation("invalid email"));
108///         }
109///         Ok(value.clone())
110///     }
111///     fn parse_literal(&self, ast: &Value) -> Result<Value> {
112///         self.parse_value(ast)
113///     }
114/// }
115///
116/// let email = Email;
117/// let result = validate_custom_scalar(&email, &json!("test@example.com"), ValidationContext::ParseValue).unwrap();
118/// assert_eq!(result, json!("test@example.com"));
119/// ```
120pub fn validate_custom_scalar(
121    scalar: &dyn CustomScalar,
122    value: &Value,
123    context: ValidationContext,
124) -> Result<Value> {
125    match context {
126        ValidationContext::Serialize => scalar.serialize(value).map_err(|e| {
127            FraiseQLError::validation(format!(
128                "Scalar \"{}\" validation failed in serialize: {}",
129                scalar.name(),
130                e
131            ))
132        }),
133
134        ValidationContext::ParseValue => scalar.parse_value(value).map_err(|e| {
135            FraiseQLError::validation(format!(
136                "Scalar \"{}\" validation failed in parseValue: {}",
137                scalar.name(),
138                e
139            ))
140        }),
141
142        ValidationContext::ParseLiteral => scalar.parse_literal(value).map_err(|e| {
143            FraiseQLError::validation(format!(
144                "Scalar \"{}\" validation failed in parseLiteral: {}",
145                scalar.name(),
146                e
147            ))
148        }),
149    }
150}
151
152/// Convenience function that defaults context to `ParseValue`.
153///
154/// # Errors
155///
156/// Returns `FraiseQLError::Validation` if the value does not conform to the scalar's format.
157pub fn validate_custom_scalar_parse_value(
158    scalar: &dyn CustomScalar,
159    value: &Value,
160) -> Result<Value> {
161    validate_custom_scalar(scalar, value, ValidationContext::ParseValue)
162}
163
164#[cfg(test)]
165mod tests {
166    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
167
168    use serde_json::{Value, json};
169
170    use super::*;
171    use crate::error::{FraiseQLError, Result};
172
173    /// Passthrough scalar that always succeeds (for happy-path tests).
174    #[derive(Debug)]
175    struct PassthroughScalar;
176
177    #[allow(clippy::unnecessary_literal_bound)] // Reason: test impl of trait returning a literal
178    impl CustomScalar for PassthroughScalar {
179        fn name(&self) -> &str {
180            "Passthrough"
181        }
182
183        fn serialize(&self, value: &Value) -> Result<Value> {
184            Ok(value.clone())
185        }
186
187        fn parse_value(&self, value: &Value) -> Result<Value> {
188            Ok(value.clone())
189        }
190
191        fn parse_literal(&self, ast: &Value) -> Result<Value> {
192            Ok(ast.clone())
193        }
194    }
195
196    /// Scalar that always fails with a descriptive error.
197    #[derive(Debug)]
198    struct FailScalar;
199
200    #[allow(clippy::unnecessary_literal_bound)] // Reason: test impl of trait returning a literal
201    impl CustomScalar for FailScalar {
202        fn name(&self) -> &str {
203            "AlwaysFail"
204        }
205
206        fn serialize(&self, _: &Value) -> Result<Value> {
207            Err(FraiseQLError::validation("serialize always fails"))
208        }
209
210        fn parse_value(&self, _: &Value) -> Result<Value> {
211            Err(FraiseQLError::validation("parse_value always fails"))
212        }
213
214        fn parse_literal(&self, _: &Value) -> Result<Value> {
215            Err(FraiseQLError::validation("parse_literal always fails"))
216        }
217    }
218
219    // ── ValidationContext tests ────────────────────────────────────────────────
220
221    #[test]
222    fn test_validation_context_as_str_serialize() {
223        assert_eq!(ValidationContext::Serialize.as_str(), "serialize");
224    }
225
226    #[test]
227    fn test_validation_context_as_str_parse_value() {
228        assert_eq!(ValidationContext::ParseValue.as_str(), "parseValue");
229    }
230
231    #[test]
232    fn test_validation_context_as_str_parse_literal() {
233        assert_eq!(ValidationContext::ParseLiteral.as_str(), "parseLiteral");
234    }
235
236    #[test]
237    fn test_validation_context_eq() {
238        assert_eq!(ValidationContext::Serialize, ValidationContext::Serialize);
239        assert_ne!(ValidationContext::Serialize, ValidationContext::ParseValue);
240    }
241
242    // ── ScalarValidationError tests ────────────────────────────────────────────
243
244    #[test]
245    fn test_scalar_validation_error_new() {
246        let err = ScalarValidationError::new("Email", "parseValue", "not an email");
247        assert_eq!(err.scalar_name, "Email");
248        assert_eq!(err.context, "parseValue");
249        assert_eq!(err.message, "not an email");
250    }
251
252    #[test]
253    fn test_scalar_validation_error_display() {
254        let err = ScalarValidationError::new("Email", "parseValue", "bad input");
255        let s = format!("{err}");
256        assert!(s.contains("Email"), "missing scalar name: {s}");
257        assert!(s.contains("parseValue"), "missing context: {s}");
258        assert!(s.contains("bad input"), "missing message: {s}");
259    }
260
261    #[test]
262    fn test_scalar_validation_error_into_fraiseql_error() {
263        let err = ScalarValidationError::new("T", "serialize", "oops");
264        let fraiseql_err = err.into_fraiseql_error();
265        let msg = format!("{fraiseql_err}");
266        assert!(msg.contains("oops"), "error message lost: {msg}");
267    }
268
269    // ── validate_custom_scalar tests ──────────────────────────────────────────
270
271    #[test]
272    fn test_validate_serialize_success() {
273        let scalar = PassthroughScalar;
274        let v = json!("hello");
275        let result = validate_custom_scalar(&scalar, &v, ValidationContext::Serialize);
276        assert_eq!(result.unwrap(), v);
277    }
278
279    #[test]
280    fn test_validate_parse_value_success() {
281        let scalar = PassthroughScalar;
282        let v = json!(42);
283        let result = validate_custom_scalar(&scalar, &v, ValidationContext::ParseValue);
284        assert_eq!(result.unwrap(), v);
285    }
286
287    #[test]
288    fn test_validate_parse_literal_success() {
289        let scalar = PassthroughScalar;
290        let v = json!(true);
291        let result = validate_custom_scalar(&scalar, &v, ValidationContext::ParseLiteral);
292        assert_eq!(result.unwrap(), v);
293    }
294
295    #[test]
296    fn test_validate_serialize_failure_wraps_error() {
297        let scalar = FailScalar;
298        let err =
299            validate_custom_scalar(&scalar, &json!("x"), ValidationContext::Serialize).unwrap_err();
300        let msg = format!("{err}");
301        assert!(
302            msg.contains("AlwaysFail") || msg.contains("serialize"),
303            "unexpected error message: {msg}"
304        );
305    }
306
307    #[test]
308    fn test_validate_parse_value_failure_wraps_error() {
309        let scalar = FailScalar;
310        let err = validate_custom_scalar(&scalar, &json!("x"), ValidationContext::ParseValue)
311            .unwrap_err();
312        let msg = format!("{err}");
313        assert!(
314            msg.contains("AlwaysFail") || msg.contains("parseValue"),
315            "unexpected error message: {msg}"
316        );
317    }
318
319    #[test]
320    fn test_validate_parse_literal_failure_wraps_error() {
321        let scalar = FailScalar;
322        let err = validate_custom_scalar(&scalar, &json!("x"), ValidationContext::ParseLiteral)
323            .unwrap_err();
324        let msg = format!("{err}");
325        assert!(
326            msg.contains("AlwaysFail") || msg.contains("parseLiteral"),
327            "unexpected error message: {msg}"
328        );
329    }
330
331    // ── validate_custom_scalar_parse_value convenience fn ─────────────────────
332
333    #[test]
334    fn test_convenience_fn_success() {
335        let scalar = PassthroughScalar;
336        let v = json!("text");
337        assert_eq!(validate_custom_scalar_parse_value(&scalar, &v).unwrap(), v);
338    }
339
340    #[test]
341    fn test_convenience_fn_failure() {
342        let scalar = FailScalar;
343        let result = validate_custom_scalar_parse_value(&scalar, &json!("x"));
344        assert!(
345            matches!(result, Err(FraiseQLError::Validation { .. })),
346            "expected Validation error, got: {result:?}"
347        );
348    }
349}