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