use crate::scalar::{ScalarType, ScalarValue};
use crate::yaml::{Document, TaggedNode};
use rowan::ast::AstNode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationErrorKind {
TypeNotAllowed {
found_type: ScalarType,
allowed_types: Vec<ScalarType>,
},
CustomConstraintFailed {
constraint_name: String,
actual_value: String,
},
CoercionFailed {
from_type: ScalarType,
to_types: Vec<ScalarType>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
pub kind: ValidationErrorKind,
pub path: String,
pub schema_name: String,
}
impl ValidationError {
pub fn type_not_allowed(
path: impl Into<String>,
schema_name: impl Into<String>,
found_type: ScalarType,
allowed_types: Vec<ScalarType>,
) -> Self {
Self {
kind: ValidationErrorKind::TypeNotAllowed {
found_type,
allowed_types,
},
path: path.into(),
schema_name: schema_name.into(),
}
}
pub fn custom_constraint_failed(
path: impl Into<String>,
schema_name: impl Into<String>,
constraint_name: impl Into<String>,
actual_value: impl Into<String>,
) -> Self {
Self {
kind: ValidationErrorKind::CustomConstraintFailed {
constraint_name: constraint_name.into(),
actual_value: actual_value.into(),
},
path: path.into(),
schema_name: schema_name.into(),
}
}
pub fn coercion_failed(
path: impl Into<String>,
schema_name: impl Into<String>,
from_type: ScalarType,
to_types: Vec<ScalarType>,
) -> Self {
Self {
kind: ValidationErrorKind::CoercionFailed {
from_type,
to_types,
},
path: path.into(),
schema_name: schema_name.into(),
}
}
pub fn message(&self) -> String {
match &self.kind {
ValidationErrorKind::TypeNotAllowed {
found_type,
allowed_types,
} => {
format!(
"type {:?} not allowed in {} schema, expected one of {:?}",
found_type, self.schema_name, allowed_types
)
}
ValidationErrorKind::CustomConstraintFailed {
constraint_name,
actual_value,
} => {
format!("custom constraint '{constraint_name}' failed for value '{actual_value}'")
}
ValidationErrorKind::CoercionFailed {
from_type,
to_types,
} => {
format!(
"cannot coerce {:?} to any of {:?} in {} schema",
from_type, to_types, self.schema_name
)
}
}
}
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Validation error at {}: {}", self.path, self.message())
}
}
impl std::error::Error for ValidationError {}
pub type ValidationResult<T> = Result<T, Vec<ValidationError>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CustomValidationResult {
Valid,
Invalid {
constraint: String,
reason: String,
},
}
impl CustomValidationResult {
pub fn invalid(constraint: impl Into<String>, reason: impl Into<String>) -> Self {
Self::Invalid {
constraint: constraint.into(),
reason: reason.into(),
}
}
pub fn is_valid(&self) -> bool {
matches!(self, Self::Valid)
}
pub fn is_invalid(&self) -> bool {
matches!(self, Self::Invalid { .. })
}
}
pub type CustomValidator = Box<dyn Fn(&str, &str) -> CustomValidationResult + Send + Sync>;
pub struct CustomSchema {
pub name: String,
pub allowed_types: Vec<ScalarType>,
pub custom_validators: std::collections::HashMap<ScalarType, CustomValidator>,
pub allow_coercion: bool,
}
impl std::fmt::Debug for CustomSchema {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CustomSchema")
.field("name", &self.name)
.field("allowed_types", &self.allowed_types)
.field("allow_coercion", &self.allow_coercion)
.field(
"validators",
&format!("<{} validators>", self.custom_validators.len()),
)
.finish()
}
}
impl CustomSchema {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
allowed_types: Vec::new(),
custom_validators: std::collections::HashMap::new(),
allow_coercion: true,
}
}
pub fn allow_type(mut self, scalar_type: ScalarType) -> Self {
if !self.allowed_types.contains(&scalar_type) {
self.allowed_types.push(scalar_type);
}
self
}
pub fn allow_types(mut self, types: &[ScalarType]) -> Self {
for &scalar_type in types {
if !self.allowed_types.contains(&scalar_type) {
self.allowed_types.push(scalar_type);
}
}
self
}
pub fn with_validator<F>(mut self, scalar_type: ScalarType, validator: F) -> Self
where
F: Fn(&str, &str) -> CustomValidationResult + Send + Sync + 'static,
{
self.custom_validators
.insert(scalar_type, Box::new(validator));
self
}
pub fn strict(mut self) -> Self {
self.allow_coercion = false;
self
}
pub fn allows_type(&self, scalar_type: ScalarType) -> bool {
self.allowed_types.contains(&scalar_type)
}
pub fn validate_scalar(&self, content: &str, path: &str) -> Result<(), ValidationError> {
self.validate_scalar_value(&ScalarValue::parse(content.trim()), path)
}
fn validate_scalar_value(
&self,
scalar_value: &ScalarValue,
path: &str,
) -> Result<(), ValidationError> {
let content = scalar_value.value();
let scalar_type = scalar_value.scalar_type();
check_type(
scalar_value,
&self.allowed_types,
self.allow_coercion,
path,
&self.name,
)?;
if let Some(validator) = self.custom_validators.get(&scalar_type) {
let result = validator(content, path);
if let CustomValidationResult::Invalid { constraint, reason } = result {
return Err(ValidationError::custom_constraint_failed(
path,
&self.name,
format!("{constraint}: {reason}"),
content,
));
}
}
Ok(())
}
}
fn check_type(
value: &ScalarValue,
allowed: &[ScalarType],
coerce: bool,
path: &str,
schema_name: &str,
) -> Result<(), ValidationError> {
let scalar_type = value.scalar_type();
if allowed.contains(&scalar_type) {
return Ok(());
}
if !coerce {
return Err(ValidationError::type_not_allowed(
path,
schema_name,
scalar_type,
allowed.to_vec(),
));
}
if allowed.iter().any(|&t| value.coerce_to_type(t).is_some()) {
Ok(())
} else {
Err(ValidationError::coercion_failed(
path,
schema_name,
scalar_type,
allowed.to_vec(),
))
}
}
#[derive(Debug)]
pub enum Schema {
Failsafe,
Json,
Core,
Custom(CustomSchema),
}
impl PartialEq for Schema {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Schema::Failsafe, Schema::Failsafe)
| (Schema::Json, Schema::Json)
| (Schema::Core, Schema::Core) => true,
(Schema::Custom(a), Schema::Custom(b)) => a.name == b.name,
_ => false,
}
}
}
impl Schema {
pub fn name(&self) -> &str {
match self {
Schema::Failsafe => "failsafe",
Schema::Json => "json",
Schema::Core => "core",
Schema::Custom(custom) => &custom.name,
}
}
pub fn allows_scalar_type(&self, scalar_type: ScalarType) -> bool {
self.allowed_scalar_types().contains(&scalar_type)
}
pub fn allowed_scalar_types(&self) -> Vec<ScalarType> {
match self {
Schema::Failsafe => vec![ScalarType::String],
Schema::Json => vec![
ScalarType::String,
ScalarType::Integer,
ScalarType::Float,
ScalarType::Boolean,
ScalarType::Null,
],
Schema::Core => vec![
ScalarType::String,
ScalarType::Integer,
ScalarType::Float,
ScalarType::Boolean,
ScalarType::Null,
#[cfg(feature = "base64")]
ScalarType::Binary,
ScalarType::Timestamp,
ScalarType::Regex,
],
Schema::Custom(custom) => custom.allowed_types.clone(),
}
}
}
#[derive(Debug)]
pub struct SchemaValidator {
schema: Schema,
strict: bool,
}
impl SchemaValidator {
pub fn new(schema: Schema) -> Self {
Self {
schema,
strict: false,
}
}
pub fn failsafe() -> Self {
Self::new(Schema::Failsafe)
}
pub fn json() -> Self {
Self::new(Schema::Json)
}
pub fn core() -> Self {
Self::new(Schema::Core)
}
pub fn custom(schema: CustomSchema) -> Self {
Self::new(Schema::Custom(schema))
}
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
pub fn schema(&self) -> &Schema {
&self.schema
}
pub fn validate(&self, document: &Document) -> ValidationResult<()> {
self.check_document(document, false)
}
pub fn can_coerce(&self, document: &Document) -> ValidationResult<()> {
self.check_document(document, !self.strict)
}
fn check_document(&self, document: &Document, coerce_only: bool) -> ValidationResult<()> {
let mut errors = Vec::new();
if let Some(root) = document.root_node() {
self.check_node(&root, "root", coerce_only, &mut errors);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn check_node(
&self,
node: &rowan::SyntaxNode<crate::yaml::Lang>,
path: &str,
coerce_only: bool,
errors: &mut Vec<ValidationError>,
) {
use crate::yaml::{extract, Mapping, Scalar, Sequence};
if let Some(scalar) = extract::<Scalar>(node) {
let value = ScalarValue::from_scalar(&scalar);
self.check_scalar(&value, false, path, coerce_only, errors);
} else if let Some(tagged_node) = extract::<TaggedNode>(node) {
if let Some(content) = tagged_node
.as_sequence()
.map(|s| s.syntax().clone())
.or_else(|| tagged_node.as_mapping().map(|m| m.syntax().clone()))
{
self.check_node(&content, path, coerce_only, errors);
} else if let Some(value) = ScalarValue::from_tagged(&tagged_node) {
let value = match tagged_node
.resolved_tag()
.as_deref()
.and_then(ScalarValue::core_tag_scalar_type)
{
Some(scalar_type) => value.with_type(scalar_type),
None => value,
};
self.check_scalar(&value, true, path, coerce_only, errors);
}
} else if let Some(sequence) = extract::<Sequence>(node) {
for (i, item) in sequence.items().enumerate() {
self.check_node(&item, &format!("{path}[{i}]"), coerce_only, errors);
}
} else if let Some(mapping) = extract::<Mapping>(node) {
for (key_node, value_node) in mapping.pairs() {
let key_name = key_node.text().to_string().trim().to_string();
self.check_node(
&value_node,
&format!("{path}.{key_name}"),
coerce_only,
errors,
);
}
}
}
fn check_scalar(
&self,
value: &ScalarValue,
tagged: bool,
path: &str,
coerce_only: bool,
errors: &mut Vec<ValidationError>,
) {
let result = match &self.schema {
Schema::Custom(custom_schema) if !coerce_only => {
custom_schema.validate_scalar_value(value, path)
}
_ => check_type(
value,
&self.schema.allowed_scalar_types(),
!tagged && !self.strict,
path,
self.schema.name(),
),
};
errors.extend(result.err());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validator_creation() {
let failsafe = SchemaValidator::failsafe();
assert_eq!(*failsafe.schema(), Schema::Failsafe);
assert!(!failsafe.strict);
let json = SchemaValidator::json();
assert_eq!(*json.schema(), Schema::Json);
let core = SchemaValidator::core();
assert_eq!(*core.schema(), Schema::Core);
let strict_validator = SchemaValidator::json().strict();
assert!(strict_validator.strict);
}
}