1use serde::{Deserialize, Serialize};
7use smallvec::SmallVec;
8use std::collections::HashMap;
9
10use crate::DomainError;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct SchemaId(String);
15
16impl SchemaId {
17 pub fn new(id: impl Into<String>) -> Self {
31 Self(id.into())
32 }
33
34 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub enum Schema {
76 String {
78 min_length: Option<usize>,
80 max_length: Option<usize>,
82 pattern: Option<String>,
84 allowed_values: Option<SmallVec<[String; 8]>>,
86 },
87
88 Integer {
90 minimum: Option<i64>,
92 maximum: Option<i64>,
94 },
95
96 Number {
98 minimum: Option<f64>,
100 maximum: Option<f64>,
102 },
103
104 Boolean,
106
107 Null,
109
110 Array {
112 items: Option<Box<Schema>>,
114 min_items: Option<usize>,
116 max_items: Option<usize>,
118 unique_items: bool,
120 },
121
122 Object {
124 properties: HashMap<String, Schema>,
126 required: Vec<String>,
128 additional_properties: bool,
130 },
131
132 OneOf {
134 schemas: SmallVec<[Box<Schema>; 4]>,
136 },
137
138 AllOf {
140 schemas: SmallVec<[Box<Schema>; 4]>,
142 },
143
144 Any,
146}
147
148pub type SchemaValidationResult<T> = Result<T, SchemaValidationError>;
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
161pub enum SchemaValidationError {
162 #[error("Type mismatch at '{path}': expected {expected}, got {actual}")]
164 TypeMismatch {
165 path: String,
167 expected: String,
169 actual: String,
171 },
172
173 #[error("Missing required field at '{path}': {field}")]
175 MissingRequired {
176 path: String,
178 field: String,
180 },
181
182 #[error("Value out of range at '{path}': {value} not in [{min}, {max}]")]
184 OutOfRange {
185 path: String,
187 value: String,
189 min: String,
191 max: String,
193 },
194
195 #[error("String length constraint at '{path}': length {actual} not in [{min}, {max}]")]
197 StringLengthConstraint {
198 path: String,
200 actual: usize,
202 min: usize,
204 max: usize,
206 },
207
208 #[error("Pattern mismatch at '{path}': value '{value}' does not match pattern '{pattern}'")]
210 PatternMismatch {
211 path: String,
213 value: String,
215 pattern: String,
217 },
218
219 #[error("Invalid pattern at '{path}': pattern '{pattern}' is not valid regex: {reason}")]
221 InvalidPattern {
222 path: String,
224 pattern: String,
226 reason: String,
228 },
229
230 #[error("Array size constraint at '{path}': size {actual} not in [{min}, {max}]")]
232 ArraySizeConstraint {
233 path: String,
235 actual: usize,
237 min: usize,
239 max: usize,
241 },
242
243 #[error("Unique items constraint at '{path}': duplicate items found")]
245 DuplicateItems {
246 path: String,
248 },
249
250 #[error("Invalid enum value at '{path}': '{value}' not in allowed values")]
252 InvalidEnumValue {
253 path: String,
255 value: String,
257 },
258
259 #[error("Additional property not allowed at '{path}': '{property}'")]
261 AdditionalPropertyNotAllowed {
262 path: String,
264 property: String,
266 },
267
268 #[error("No matching schema in OneOf at '{path}'")]
270 NoMatchingOneOf {
271 path: String,
273 },
274
275 #[error("Not all schemas match in AllOf at '{path}': {failures}")]
277 AllOfFailure {
278 path: String,
280 failures: String,
282 },
283}
284
285impl Schema {
286 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 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, 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 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 pub fn integer(minimum: Option<i64>, maximum: Option<i64>) -> Self {
365 Self::Integer { minimum, maximum }
366 }
367
368 pub fn number(minimum: Option<f64>, maximum: Option<f64>) -> Self {
370 Self::Number { minimum, maximum }
371 }
372
373 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
395pub enum SchemaType {
396 String,
398 Integer,
400 Number,
402 Boolean,
404 Null,
406 Array,
408 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, 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}