Skip to main content

pjson_rs_domain/value_objects/
schema.rs

1//! Schema value object for JSON validation
2//!
3//! Represents a JSON schema definition following a subset of JSON Schema specification.
4//! This is a domain value object with no identity, defined purely by its attributes.
5
6use serde::{Deserialize, Serialize};
7use smallvec::SmallVec;
8use std::collections::HashMap;
9
10use crate::DomainError;
11
12/// Schema identifier for tracking and referencing schemas
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct SchemaId(String);
15
16impl SchemaId {
17    /// Create a new schema identifier
18    ///
19    /// # Arguments
20    /// * `id` - Unique schema identifier string
21    ///
22    /// # Returns
23    /// New schema ID instance
24    ///
25    /// # Examples
26    /// ```
27    /// # use pjson_rs_domain::value_objects::SchemaId;
28    /// let schema_id = SchemaId::new("user-profile-v1");
29    /// ```
30    pub fn new(id: impl Into<String>) -> Self {
31        Self(id.into())
32    }
33
34    /// Get schema ID as string slice
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl std::fmt::Display for SchemaId {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        write!(f, "{}", self.0)
43    }
44}
45
46/// JSON Schema representation for validation
47///
48/// Supports a practical subset of JSON Schema Draft 2020-12 focused on
49/// validation needs for streaming JSON data.
50///
51/// # Design Philosophy
52/// - Focused on validation, not full JSON Schema specification
53/// - Performance-oriented with pre-compiled validation rules
54/// - Zero-copy where possible using Arc for shared data
55/// - Type-safe with strongly-typed enum variants
56///
57/// # Examples
58/// ```
59/// # use pjson_rs_domain::value_objects::{Schema, SchemaType};
60/// let schema = Schema::Object {
61///     properties: vec![
62///         ("id".to_string(), Schema::Integer { minimum: Some(1), maximum: None }),
63///         ("name".to_string(), Schema::String {
64///             min_length: Some(1),
65///             max_length: Some(100),
66///             pattern: None,
67///             allowed_values: None,
68///         }),
69///     ].into_iter().collect(),
70///     required: vec!["id".to_string(), "name".to_string()],
71///     additional_properties: false,
72/// };
73/// ```
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub enum Schema {
76    /// String type with optional constraints
77    String {
78        /// Minimum string length (inclusive)
79        min_length: Option<usize>,
80        /// Maximum string length (inclusive)
81        max_length: Option<usize>,
82        /// Pattern to match (regex)
83        pattern: Option<String>,
84        /// Enumeration of allowed values
85        allowed_values: Option<SmallVec<[String; 8]>>,
86    },
87
88    /// Integer type with optional range constraints
89    Integer {
90        /// Minimum value (inclusive)
91        minimum: Option<i64>,
92        /// Maximum value (inclusive)
93        maximum: Option<i64>,
94    },
95
96    /// Number type (float/double) with optional range constraints
97    Number {
98        /// Minimum value (inclusive)
99        minimum: Option<f64>,
100        /// Maximum value (inclusive)
101        maximum: Option<f64>,
102    },
103
104    /// Boolean type (no constraints)
105    Boolean,
106
107    /// Null type (no constraints)
108    Null,
109
110    /// Array type with element schema and size constraints
111    Array {
112        /// Schema for array elements (None = any type)
113        items: Option<Box<Schema>>,
114        /// Minimum array length (inclusive)
115        min_items: Option<usize>,
116        /// Maximum array length (inclusive)
117        max_items: Option<usize>,
118        /// Whether all items must be unique
119        unique_items: bool,
120    },
121
122    /// Object type with property schemas
123    Object {
124        /// Property name to schema mapping
125        properties: HashMap<String, Schema>,
126        /// List of required property names
127        required: Vec<String>,
128        /// Whether additional properties are allowed
129        additional_properties: bool,
130    },
131
132    /// Union type (one of multiple schemas)
133    OneOf {
134        /// List of possible schemas
135        schemas: SmallVec<[Box<Schema>; 4]>,
136    },
137
138    /// Intersection type (all of multiple schemas)
139    AllOf {
140        /// List of schemas that must all match
141        schemas: SmallVec<[Box<Schema>; 4]>,
142    },
143
144    /// Any type (no validation)
145    Any,
146}
147
148/// Schema validation result
149pub type SchemaValidationResult<T> = Result<T, SchemaValidationError>;
150
151/// Schema validation error with detailed context
152///
153/// Provides rich error information including the JSON path where validation failed,
154/// expected vs actual values, and human-readable error messages.
155///
156/// # Design
157/// - Includes full path context for nested validation failures
158/// - Provides actionable error messages for debugging
159/// - Zero-allocation for common error cases using `String`
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
161pub enum SchemaValidationError {
162    /// Type mismatch error
163    #[error("Type mismatch at '{path}': expected {expected}, got {actual}")]
164    TypeMismatch {
165        /// JSON path where error occurred
166        path: String,
167        /// Expected type
168        expected: String,
169        /// Actual type
170        actual: String,
171    },
172
173    /// Missing required field
174    #[error("Missing required field at '{path}': {field}")]
175    MissingRequired {
176        /// JSON path to parent object
177        path: String,
178        /// Name of missing field
179        field: String,
180    },
181
182    /// Value out of range
183    #[error("Value out of range at '{path}': {value} not in [{min}, {max}]")]
184    OutOfRange {
185        /// JSON path where error occurred
186        path: String,
187        /// Actual value
188        value: String,
189        /// Minimum allowed value
190        min: String,
191        /// Maximum allowed value
192        max: String,
193    },
194
195    /// String length constraint violation
196    #[error("String length constraint at '{path}': length {actual} not in [{min}, {max}]")]
197    StringLengthConstraint {
198        /// JSON path where error occurred
199        path: String,
200        /// Actual string length
201        actual: usize,
202        /// Minimum allowed length
203        min: usize,
204        /// Maximum allowed length
205        max: usize,
206    },
207
208    /// Pattern mismatch
209    #[error("Pattern mismatch at '{path}': value '{value}' does not match pattern '{pattern}'")]
210    PatternMismatch {
211        /// JSON path where error occurred
212        path: String,
213        /// Actual value
214        value: String,
215        /// Expected pattern
216        pattern: String,
217    },
218
219    /// Invalid regex pattern in schema
220    #[error("Invalid pattern at '{path}': pattern '{pattern}' is not valid regex: {reason}")]
221    InvalidPattern {
222        /// JSON path where error occurred
223        path: String,
224        /// The invalid pattern string
225        pattern: String,
226        /// Regex compilation error message
227        reason: String,
228    },
229
230    /// Array size constraint violation
231    #[error("Array size constraint at '{path}': size {actual} not in [{min}, {max}]")]
232    ArraySizeConstraint {
233        /// JSON path where error occurred
234        path: String,
235        /// Actual array size
236        actual: usize,
237        /// Minimum allowed size
238        min: usize,
239        /// Maximum allowed size
240        max: usize,
241    },
242
243    /// Unique items constraint violation
244    #[error("Unique items constraint at '{path}': duplicate items found")]
245    DuplicateItems {
246        /// JSON path where error occurred
247        path: String,
248    },
249
250    /// Invalid enum value
251    #[error("Invalid enum value at '{path}': '{value}' not in allowed values")]
252    InvalidEnumValue {
253        /// JSON path where error occurred
254        path: String,
255        /// Actual value
256        value: String,
257    },
258
259    /// Additional properties not allowed
260    #[error("Additional property not allowed at '{path}': '{property}'")]
261    AdditionalPropertyNotAllowed {
262        /// JSON path where error occurred
263        path: String,
264        /// Property name
265        property: String,
266    },
267
268    /// No matching schema in OneOf
269    #[error("No matching schema in OneOf at '{path}'")]
270    NoMatchingOneOf {
271        /// JSON path where error occurred
272        path: String,
273    },
274
275    /// Not all schemas match in AllOf
276    #[error("Not all schemas match in AllOf at '{path}': {failures}")]
277    AllOfFailure {
278        /// JSON path where error occurred
279        path: String,
280        /// List of failing schema indices
281        failures: String,
282    },
283}
284
285impl Schema {
286    /// Check if schema allows a specific type
287    ///
288    /// Used for quick type compatibility checks before full validation.
289    ///
290    /// # Arguments
291    /// * `schema_type` - The type to check compatibility for
292    ///
293    /// # Returns
294    /// `true` if the schema allows the type, `false` otherwise
295    pub fn allows_type(&self, schema_type: SchemaType) -> bool {
296        match (self, schema_type) {
297            (Self::String { .. }, SchemaType::String) => true,
298            (Self::Integer { .. }, SchemaType::Integer) => true,
299            (Self::Number { .. }, SchemaType::Number) => true,
300            (Self::Boolean, SchemaType::Boolean) => true,
301            (Self::Null, SchemaType::Null) => true,
302            (Self::Array { .. }, SchemaType::Array) => true,
303            (Self::Object { .. }, SchemaType::Object) => true,
304            (Self::Any, _) => true,
305            (Self::OneOf { schemas }, schema_type) => {
306                schemas.iter().any(|s| s.allows_type(schema_type))
307            }
308            (Self::AllOf { schemas }, schema_type) => {
309                schemas.iter().all(|s| s.allows_type(schema_type))
310            }
311            _ => false,
312        }
313    }
314
315    /// Get estimated validation cost for performance optimization
316    ///
317    /// Higher cost indicates more expensive validation operations.
318    /// Used by validation scheduler to optimize validation order.
319    ///
320    /// # Returns
321    /// Validation cost estimate (0-1000 range)
322    pub fn validation_cost(&self) -> usize {
323        match self {
324            Self::Null | Self::Boolean | Self::Any => 1,
325            Self::Integer { .. } | Self::Number { .. } => 5,
326            Self::String {
327                pattern: Some(_), ..
328            } => 50, // Regex is expensive
329            Self::String { .. } => 10,
330            Self::Array { items, .. } => {
331                let item_cost = items.as_ref().map_or(1, |s| s.validation_cost());
332                10 + item_cost
333            }
334            Self::Object { properties, .. } => {
335                let prop_cost: usize = properties.values().map(|s| s.validation_cost()).sum();
336                20 + prop_cost
337            }
338            Self::OneOf { schemas } => {
339                let max_cost = schemas
340                    .iter()
341                    .map(|s| s.validation_cost())
342                    .max()
343                    .unwrap_or(0);
344                30 + max_cost * schemas.len()
345            }
346            Self::AllOf { schemas } => {
347                let total_cost: usize = schemas.iter().map(|s| s.validation_cost()).sum();
348                20 + total_cost
349            }
350        }
351    }
352
353    /// Create a simple string schema with length constraints
354    pub fn string(min_length: Option<usize>, max_length: Option<usize>) -> Self {
355        Self::String {
356            min_length,
357            max_length,
358            pattern: None,
359            allowed_values: None,
360        }
361    }
362
363    /// Create a simple integer schema with range constraints
364    pub fn integer(minimum: Option<i64>, maximum: Option<i64>) -> Self {
365        Self::Integer { minimum, maximum }
366    }
367
368    /// Create a simple number schema with range constraints
369    pub fn number(minimum: Option<f64>, maximum: Option<f64>) -> Self {
370        Self::Number { minimum, maximum }
371    }
372
373    /// Create an array schema with item type
374    pub fn array(items: Option<Schema>) -> Self {
375        Self::Array {
376            items: items.map(Box::new),
377            min_items: None,
378            max_items: None,
379            unique_items: false,
380        }
381    }
382
383    /// Create an object schema with properties
384    pub fn object(properties: HashMap<String, Schema>, required: Vec<String>) -> Self {
385        Self::Object {
386            properties,
387            required,
388            additional_properties: true,
389        }
390    }
391}
392
393/// Simplified schema type for quick type checking
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
395pub enum SchemaType {
396    /// String type
397    String,
398    /// Integer type
399    Integer,
400    /// Floating-point number type
401    Number,
402    /// Boolean type
403    Boolean,
404    /// Null type
405    Null,
406    /// Array type
407    Array,
408    /// Object type
409    Object,
410}
411
412impl From<&Schema> for SchemaType {
413    fn from(schema: &Schema) -> Self {
414        match schema {
415            Schema::String { .. } => Self::String,
416            Schema::Integer { .. } => Self::Integer,
417            Schema::Number { .. } => Self::Number,
418            Schema::Boolean => Self::Boolean,
419            Schema::Null => Self::Null,
420            Schema::Array { .. } => Self::Array,
421            Schema::Object { .. } => Self::Object,
422            Schema::Any => Self::Object, // Default to most flexible
423            Schema::OneOf { .. } | Schema::AllOf { .. } => Self::Object,
424        }
425    }
426}
427
428impl From<DomainError> for SchemaValidationError {
429    fn from(error: DomainError) -> Self {
430        match error {
431            DomainError::ValidationError(msg) => Self::TypeMismatch {
432                path: "/".to_string(),
433                expected: "valid".to_string(),
434                actual: msg,
435            },
436            _ => Self::TypeMismatch {
437                path: "/".to_string(),
438                expected: "valid".to_string(),
439                actual: error.to_string(),
440            },
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_schema_id_creation() {
451        let id = SchemaId::new("test-schema-v1");
452        assert_eq!(id.as_str(), "test-schema-v1");
453        assert_eq!(id.to_string(), "test-schema-v1");
454    }
455
456    #[test]
457    fn test_schema_allows_type() {
458        let string_schema = Schema::string(Some(1), Some(100));
459        assert!(string_schema.allows_type(SchemaType::String));
460        assert!(!string_schema.allows_type(SchemaType::Integer));
461
462        let any_schema = Schema::Any;
463        assert!(any_schema.allows_type(SchemaType::String));
464        assert!(any_schema.allows_type(SchemaType::Integer));
465    }
466
467    #[test]
468    fn test_validation_cost() {
469        let simple = Schema::Boolean;
470        assert_eq!(simple.validation_cost(), 1);
471
472        let complex = Schema::Object {
473            properties: [
474                ("id".to_string(), Schema::integer(None, None)),
475                ("name".to_string(), Schema::string(Some(1), Some(100))),
476            ]
477            .into_iter()
478            .collect(),
479            required: vec!["id".to_string()],
480            additional_properties: false,
481        };
482        assert!(complex.validation_cost() > 20);
483    }
484
485    #[test]
486    fn test_schema_builders() {
487        let str_schema = Schema::string(Some(1), Some(100));
488        assert!(matches!(str_schema, Schema::String { .. }));
489
490        let int_schema = Schema::integer(Some(0), Some(100));
491        assert!(matches!(int_schema, Schema::Integer { .. }));
492
493        let arr_schema = Schema::array(Some(Schema::integer(None, None)));
494        assert!(matches!(arr_schema, Schema::Array { .. }));
495    }
496}