use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use std::collections::HashMap;
use crate::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SchemaId(String);
impl SchemaId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SchemaId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Schema {
String {
min_length: Option<usize>,
max_length: Option<usize>,
pattern: Option<String>,
allowed_values: Option<SmallVec<[String; 8]>>,
},
Integer {
minimum: Option<i64>,
maximum: Option<i64>,
},
Number {
minimum: Option<f64>,
maximum: Option<f64>,
},
Boolean,
Null,
Array {
items: Option<Box<Schema>>,
min_items: Option<usize>,
max_items: Option<usize>,
unique_items: bool,
},
Object {
properties: HashMap<String, Schema>,
required: Vec<String>,
additional_properties: bool,
},
OneOf {
schemas: SmallVec<[Box<Schema>; 4]>,
},
AllOf {
schemas: SmallVec<[Box<Schema>; 4]>,
},
Any,
}
pub type SchemaValidationResult<T> = Result<T, SchemaValidationError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
pub enum SchemaValidationError {
#[error("Type mismatch at '{path}': expected {expected}, got {actual}")]
TypeMismatch {
path: String,
expected: String,
actual: String,
},
#[error("Missing required field at '{path}': {field}")]
MissingRequired {
path: String,
field: String,
},
#[error("Value out of range at '{path}': {value} not in [{min}, {max}]")]
OutOfRange {
path: String,
value: String,
min: String,
max: String,
},
#[error("String length constraint at '{path}': length {actual} not in [{min}, {max}]")]
StringLengthConstraint {
path: String,
actual: usize,
min: usize,
max: usize,
},
#[error("Pattern mismatch at '{path}': value '{value}' does not match pattern '{pattern}'")]
PatternMismatch {
path: String,
value: String,
pattern: String,
},
#[error("Invalid pattern at '{path}': pattern '{pattern}' is not valid regex: {reason}")]
InvalidPattern {
path: String,
pattern: String,
reason: String,
},
#[error("Array size constraint at '{path}': size {actual} not in [{min}, {max}]")]
ArraySizeConstraint {
path: String,
actual: usize,
min: usize,
max: usize,
},
#[error("Unique items constraint at '{path}': duplicate items found")]
DuplicateItems {
path: String,
},
#[error("Invalid enum value at '{path}': '{value}' not in allowed values")]
InvalidEnumValue {
path: String,
value: String,
},
#[error("Additional property not allowed at '{path}': '{property}'")]
AdditionalPropertyNotAllowed {
path: String,
property: String,
},
#[error("No matching schema in OneOf at '{path}'")]
NoMatchingOneOf {
path: String,
},
#[error("Not all schemas match in AllOf at '{path}': {failures}")]
AllOfFailure {
path: String,
failures: String,
},
}
impl Schema {
pub fn allows_type(&self, schema_type: SchemaType) -> bool {
match (self, schema_type) {
(Self::String { .. }, SchemaType::String) => true,
(Self::Integer { .. }, SchemaType::Integer) => true,
(Self::Number { .. }, SchemaType::Number) => true,
(Self::Boolean, SchemaType::Boolean) => true,
(Self::Null, SchemaType::Null) => true,
(Self::Array { .. }, SchemaType::Array) => true,
(Self::Object { .. }, SchemaType::Object) => true,
(Self::Any, _) => true,
(Self::OneOf { schemas }, schema_type) => {
schemas.iter().any(|s| s.allows_type(schema_type))
}
(Self::AllOf { schemas }, schema_type) => {
schemas.iter().all(|s| s.allows_type(schema_type))
}
_ => false,
}
}
pub fn validation_cost(&self) -> usize {
match self {
Self::Null | Self::Boolean | Self::Any => 1,
Self::Integer { .. } | Self::Number { .. } => 5,
Self::String {
pattern: Some(_), ..
} => 50, Self::String { .. } => 10,
Self::Array { items, .. } => {
let item_cost = items.as_ref().map_or(1, |s| s.validation_cost());
10 + item_cost
}
Self::Object { properties, .. } => {
let prop_cost: usize = properties.values().map(|s| s.validation_cost()).sum();
20 + prop_cost
}
Self::OneOf { schemas } => {
let max_cost = schemas
.iter()
.map(|s| s.validation_cost())
.max()
.unwrap_or(0);
30 + max_cost * schemas.len()
}
Self::AllOf { schemas } => {
let total_cost: usize = schemas.iter().map(|s| s.validation_cost()).sum();
20 + total_cost
}
}
}
pub fn string(min_length: Option<usize>, max_length: Option<usize>) -> Self {
Self::String {
min_length,
max_length,
pattern: None,
allowed_values: None,
}
}
pub fn integer(minimum: Option<i64>, maximum: Option<i64>) -> Self {
Self::Integer { minimum, maximum }
}
pub fn number(minimum: Option<f64>, maximum: Option<f64>) -> Self {
Self::Number { minimum, maximum }
}
pub fn array(items: Option<Schema>) -> Self {
Self::Array {
items: items.map(Box::new),
min_items: None,
max_items: None,
unique_items: false,
}
}
pub fn object(properties: HashMap<String, Schema>, required: Vec<String>) -> Self {
Self::Object {
properties,
required,
additional_properties: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SchemaType {
String,
Integer,
Number,
Boolean,
Null,
Array,
Object,
}
impl From<&Schema> for SchemaType {
fn from(schema: &Schema) -> Self {
match schema {
Schema::String { .. } => Self::String,
Schema::Integer { .. } => Self::Integer,
Schema::Number { .. } => Self::Number,
Schema::Boolean => Self::Boolean,
Schema::Null => Self::Null,
Schema::Array { .. } => Self::Array,
Schema::Object { .. } => Self::Object,
Schema::Any => Self::Object, Schema::OneOf { .. } | Schema::AllOf { .. } => Self::Object,
}
}
}
impl From<DomainError> for SchemaValidationError {
fn from(error: DomainError) -> Self {
match error {
DomainError::ValidationError(msg) => Self::TypeMismatch {
path: "/".to_string(),
expected: "valid".to_string(),
actual: msg,
},
_ => Self::TypeMismatch {
path: "/".to_string(),
expected: "valid".to_string(),
actual: error.to_string(),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_id_creation() {
let id = SchemaId::new("test-schema-v1");
assert_eq!(id.as_str(), "test-schema-v1");
assert_eq!(id.to_string(), "test-schema-v1");
}
#[test]
fn test_schema_allows_type() {
let string_schema = Schema::string(Some(1), Some(100));
assert!(string_schema.allows_type(SchemaType::String));
assert!(!string_schema.allows_type(SchemaType::Integer));
let any_schema = Schema::Any;
assert!(any_schema.allows_type(SchemaType::String));
assert!(any_schema.allows_type(SchemaType::Integer));
}
#[test]
fn test_validation_cost() {
let simple = Schema::Boolean;
assert_eq!(simple.validation_cost(), 1);
let complex = Schema::Object {
properties: [
("id".to_string(), Schema::integer(None, None)),
("name".to_string(), Schema::string(Some(1), Some(100))),
]
.into_iter()
.collect(),
required: vec!["id".to_string()],
additional_properties: false,
};
assert!(complex.validation_cost() > 20);
}
#[test]
fn test_schema_builders() {
let str_schema = Schema::string(Some(1), Some(100));
assert!(matches!(str_schema, Schema::String { .. }));
let int_schema = Schema::integer(Some(0), Some(100));
assert!(matches!(int_schema, Schema::Integer { .. }));
let arr_schema = Schema::array(Some(Schema::integer(None, None)));
assert!(matches!(arr_schema, Schema::Array { .. }));
}
}