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)]
75#[non_exhaustive]
76pub enum Schema {
77 String {
79 min_length: Option<usize>,
81 max_length: Option<usize>,
83 pattern: Option<String>,
85 allowed_values: Option<SmallVec<[String; 8]>>,
87 },
88
89 Integer {
91 minimum: Option<i64>,
93 maximum: Option<i64>,
95 },
96
97 Number {
99 minimum: Option<f64>,
101 maximum: Option<f64>,
103 },
104
105 Boolean,
107
108 Null,
110
111 Array {
113 items: Option<Box<Schema>>,
115 min_items: Option<usize>,
117 max_items: Option<usize>,
119 unique_items: bool,
121 },
122
123 Object {
125 properties: HashMap<String, Schema>,
127 required: Vec<String>,
129 additional_properties: bool,
131 },
132
133 OneOf {
135 schemas: SmallVec<[Box<Schema>; 4]>,
137 },
138
139 AllOf {
141 schemas: SmallVec<[Box<Schema>; 4]>,
143 },
144
145 Any,
147}
148
149pub type SchemaValidationResult<T> = Result<T, SchemaValidationError>;
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
162#[non_exhaustive]
163pub enum SchemaValidationError {
164 #[error("Type mismatch at '{path}': expected {expected}, got {actual}")]
166 TypeMismatch {
167 path: String,
169 expected: String,
171 actual: String,
173 },
174
175 #[error("Missing required field at '{path}': {field}")]
177 MissingRequired {
178 path: String,
180 field: String,
182 },
183
184 #[error("Value out of range at '{path}': {value} not in [{min}, {max}]")]
186 OutOfRange {
187 path: String,
189 value: String,
191 min: String,
193 max: String,
195 },
196
197 #[error("String length constraint at '{path}': length {actual} not in [{min}, {max}]")]
199 StringLengthConstraint {
200 path: String,
202 actual: usize,
204 min: usize,
206 max: usize,
208 },
209
210 #[error("Pattern mismatch at '{path}': value '{value}' does not match pattern '{pattern}'")]
212 PatternMismatch {
213 path: String,
215 value: String,
217 pattern: String,
219 },
220
221 #[error("Invalid pattern at '{path}': pattern '{pattern}' is not valid regex: {reason}")]
223 InvalidPattern {
224 path: String,
226 pattern: String,
228 reason: String,
230 },
231
232 #[error("Array size constraint at '{path}': size {actual} not in [{min}, {max}]")]
234 ArraySizeConstraint {
235 path: String,
237 actual: usize,
239 min: usize,
241 max: usize,
243 },
244
245 #[error("Unique items constraint at '{path}': duplicate items found")]
247 DuplicateItems {
248 path: String,
250 },
251
252 #[error("Invalid enum value at '{path}': '{value}' not in allowed values")]
254 InvalidEnumValue {
255 path: String,
257 value: String,
259 },
260
261 #[error("Additional property not allowed at '{path}': '{property}'")]
263 AdditionalPropertyNotAllowed {
264 path: String,
266 property: String,
268 },
269
270 #[error("No matching schema in OneOf at '{path}'")]
272 NoMatchingOneOf {
273 path: String,
275 },
276
277 #[error("Not all schemas match in AllOf at '{path}': {failures}")]
279 AllOfFailure {
280 path: String,
282 failures: String,
284 },
285}
286
287impl Schema {
288 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 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, 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 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 pub fn integer(minimum: Option<i64>, maximum: Option<i64>) -> Self {
367 Self::Integer { minimum, maximum }
368 }
369
370 pub fn number(minimum: Option<f64>, maximum: Option<f64>) -> Self {
372 Self::Number { minimum, maximum }
373 }
374
375 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
397#[non_exhaustive]
398pub enum SchemaType {
399 String,
401 Integer,
403 Number,
405 Boolean,
407 Null,
409 Array,
411 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, 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}