use std::collections::HashSet;
use std::fmt::Display;
use std::rc::Rc;
use hashlink::LinkedHashMap;
use jsonptr::Token;
use log::debug;
use log::error;
use saphyr::{AnnotatedMapping, MarkedYaml, Scalar, YamlData};
use crate::ConstValue;
use crate::Context;
use crate::Error;
use crate::RefUri;
use crate::Reference;
use crate::Result;
use crate::Validator;
use crate::loader::load_boolean_or_schema_marked;
use crate::loader::load_external_schema;
use crate::loader::marked_yaml_to_string;
use crate::schemas::AllOfSchema;
use crate::schemas::AnyOfSchema;
use crate::schemas::ArraySchema;
use crate::schemas::EnumSchema;
use crate::schemas::IfThenElseSchema;
use crate::schemas::IntegerSchema;
use crate::schemas::NotSchema;
use crate::schemas::NumberSchema;
use crate::schemas::ObjectSchema;
use crate::schemas::OneOfSchema;
use crate::schemas::StringSchema;
use crate::utils::format_annotated_mapping;
use crate::utils::format_linked_hash_map;
use crate::utils::format_marked_yaml;
use crate::utils::format_marker;
use crate::utils::format_scalar;
use crate::utils::format_vec;
use crate::utils::format_yaml_data;
use crate::utils::scalar_to_string;
use crate::validation::ArrayUnevaluatedAnnotations;
#[derive(Debug, PartialEq)]
pub enum YamlSchema {
Empty, Null, BooleanLiteral(bool), Subschema(Box<Subschema>),
}
impl YamlSchema {
pub fn subschema(subschema: Subschema) -> Self {
Self::Subschema(Box::new(subschema))
}
pub fn ref_str(ref_name: impl Into<String>) -> Self {
Self::subschema(Subschema {
r#ref: Some(Reference::new(ref_name)),
..Default::default()
})
}
pub fn typed_boolean() -> Self {
Self::subschema(Subschema {
r#type: SchemaType::new("boolean"),
..Default::default()
})
}
pub fn typed_number(number_schema: NumberSchema) -> Self {
number_schema.into()
}
pub fn typed_string(string_schema: StringSchema) -> Self {
Self::subschema(Subschema {
r#type: SchemaType::new("string"),
string_schema: Some(string_schema),
..Default::default()
})
}
pub fn typed_object(object_schema: ObjectSchema) -> Self {
Self::subschema(Subschema {
r#type: SchemaType::new("object"),
object_schema: Some(object_schema),
..Default::default()
})
}
pub fn resolve(
&self,
key: Option<&Token>,
components: &[jsonptr::Component],
) -> Option<&YamlSchema> {
debug!("[YamlSchema#resolve] self: {self}, key: {key:?}, components: {components:?}");
if components.is_empty() {
return Some(self);
}
match self {
YamlSchema::Subschema(subschema) => subschema.resolve(key, components),
_ => None,
}
}
}
impl<'r> TryFrom<&MarkedYaml<'r>> for YamlSchema {
type Error = crate::Error;
fn try_from(marked_yaml: &MarkedYaml<'r>) -> crate::Result<Self> {
match &marked_yaml.data {
YamlData::Value(scalar) => match scalar {
Scalar::Boolean(value) => Ok(YamlSchema::BooleanLiteral(*value)),
Scalar::Null => Ok(YamlSchema::Null),
_ => Err(generic_error!(
"[YamlSchema#try_from] Expected a boolean or null, but got: {}",
format_scalar(scalar)
)),
},
YamlData::Mapping(_) => Subschema::try_from(marked_yaml).map(YamlSchema::subschema),
_ => Err(generic_error!(
"[YamlSchema#try_from] Expected a boolean, null, or a mapping, but got: {}",
format_marked_yaml(marked_yaml)
)),
}
}
}
impl From<NumberSchema> for YamlSchema {
fn from(number_schema: NumberSchema) -> Self {
YamlSchema::subschema(Subschema {
r#type: SchemaType::new("number"),
number_schema: Some(number_schema),
..Default::default()
})
}
}
impl From<IntegerSchema> for YamlSchema {
fn from(integer_schema: IntegerSchema) -> Self {
YamlSchema::subschema(Subschema {
r#type: SchemaType::new("integer"),
integer_schema: Some(integer_schema),
..Default::default()
})
}
}
impl From<StringSchema> for YamlSchema {
fn from(string_schema: StringSchema) -> Self {
YamlSchema::subschema(Subschema {
r#type: SchemaType::new("string"),
string_schema: Some(string_schema),
..Default::default()
})
}
}
impl Validator for YamlSchema {
fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> Result<()> {
debug!("[YamlSchema] self: {self}");
debug!(
"[YamlSchema] Validating value: {}",
format_yaml_data(&value.data)
);
match self {
YamlSchema::Empty => Ok(()),
YamlSchema::Null => {
if !matches!(&value.data, YamlData::Value(Scalar::Null)) {
context.add_error(
value,
format!("Expected null, but got: {}", format_yaml_data(&value.data)),
);
}
Ok(())
}
YamlSchema::BooleanLiteral(boolean) => {
if !*boolean {
context.add_error(value, "YamlSchema is `false`!");
}
Ok(())
}
YamlSchema::Subschema(subschema) => {
debug!("[YamlSchema#validate] Validating subschema: {subschema:?}");
subschema.validate(context, value)?;
Ok(())
}
}
}
}
impl From<Subschema> for YamlSchema {
fn from(subschema: Subschema) -> Self {
YamlSchema::subschema(subschema)
}
}
impl Display for YamlSchema {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
YamlSchema::Empty => write!(f, "<empty>"),
YamlSchema::Null => write!(f, "null"),
YamlSchema::BooleanLiteral(value) => write!(f, "{value}"),
YamlSchema::Subschema(subschema) => subschema.fmt(f),
}
}
}
#[derive(Debug, PartialEq)]
pub enum BooleanOrSchema {
Boolean(bool),
Schema(YamlSchema),
}
impl BooleanOrSchema {
pub fn schema(schema: YamlSchema) -> Self {
BooleanOrSchema::Schema(schema)
}
}
impl Display for BooleanOrSchema {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BooleanOrSchema::Boolean(value) => write!(f, "{value}"),
BooleanOrSchema::Schema(schema) => schema.fmt(f),
}
}
}
#[derive(Debug, Default, PartialEq)]
pub enum SchemaType {
#[default]
None,
Single(String),
Multiple(Vec<String>),
}
impl SchemaType {
pub fn new<S: Into<String>>(value: S) -> Self {
SchemaType::Single(value.into())
}
pub fn is_none(&self) -> bool {
matches!(self, SchemaType::None)
}
pub fn is_single(&self) -> bool {
matches!(self, SchemaType::Single(_))
}
pub fn is_multiple(&self) -> bool {
matches!(self, SchemaType::Multiple(_))
}
pub fn is_or_contains(&self, r#type: &str) -> bool {
match self {
SchemaType::None => false,
SchemaType::Single(s) => s == r#type,
SchemaType::Multiple(values) => values.contains(&r#type.to_string()),
}
}
}
impl Display for SchemaType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SchemaType::None => Ok(()), SchemaType::Single(value) => write!(f, "{value}"),
SchemaType::Multiple(values) => write!(f, "{}", format_vec(values)),
}
}
}
#[derive(Debug, Default, PartialEq)]
pub struct Subschema {
pub metadata_and_annotations: MetadataAndAnnotations,
pub anchor: Option<String>,
pub r#ref: Option<Reference>,
pub defs: Option<LinkedHashMap<String, YamlSchema>>,
pub any_of: Option<AnyOfSchema>,
pub all_of: Option<AllOfSchema>,
pub one_of: Option<OneOfSchema>,
pub not: Option<NotSchema>,
pub if_then_else: Option<IfThenElseSchema>,
pub r#type: SchemaType,
pub r#const: Option<ConstValue>,
pub r#enum: Option<EnumSchema>,
pub array_schema: Option<ArraySchema>,
pub integer_schema: Option<IntegerSchema>,
pub number_schema: Option<NumberSchema>,
pub object_schema: Option<ObjectSchema>,
pub string_schema: Option<StringSchema>,
pub unevaluated_properties: Option<BooleanOrSchema>,
pub unevaluated_items: Option<BooleanOrSchema>,
}
impl Subschema {
pub fn resolve(
&self,
token: Option<&Token>,
components: &[jsonptr::Component],
) -> Option<&YamlSchema> {
debug!("[Subschema#resolve] self: {self}, token: {token:?}, components: {components:?}");
if let Some(token) = token {
let s = token.decoded();
debug!("[Subschema#resolve] key: {s}");
match s.as_ref() {
"$defs" => {
debug!("[Subschema#resolve] Resolving $defs");
if let Some(defs) = self.defs.as_ref() {
debug!("[Subschema#resolve] defs: {}", format_linked_hash_map(defs));
if let Some(component) = components.first() {
debug!("[Subschema#resolve] component: {component:?}");
if let jsonptr::Component::Token(next_token) = component {
let decoded = next_token.decoded();
debug!("[Subschema#resolve] decoded: {decoded}");
debug!("[Subschema#resolve] defs: {defs:?}");
if let Some(schema) = defs.get(decoded.as_ref()) {
debug!("[Subschema#resolve] schema: {schema:?}");
return schema.resolve(Some(next_token), &components[1..]);
}
}
}
}
}
"anyOf" => {}
_ => (),
}
}
None
}
}
impl<'r> TryFrom<&MarkedYaml<'r>> for Subschema {
type Error = crate::Error;
fn try_from(marked_yaml: &MarkedYaml<'r>) -> crate::Result<Self> {
if let YamlData::Mapping(mapping) = &marked_yaml.data {
Self::try_from(mapping)
} else {
Err(generic_error!(
"{} Expected a mapping, but got: {:?}",
format_marker(&marked_yaml.span.start),
marked_yaml
))
}
}
}
fn try_load_defs<'r>(marked_yaml: &MarkedYaml<'r>) -> Result<LinkedHashMap<String, YamlSchema>> {
debug!(
"[try_load_defs] marked_yaml: {}",
format_yaml_data(&marked_yaml.data)
);
if let YamlData::Mapping(mapping) = &marked_yaml.data {
debug!(
"[try_load_defs] mapping: {}",
format_annotated_mapping(mapping)
);
mapping
.iter()
.try_fold(LinkedHashMap::new(), |mut acc, (key, value)| {
let key = marked_yaml_to_string(key, "key must be a string")?;
acc.insert(key, value.try_into()?);
Ok(acc)
})
} else {
Err(expected_mapping!(marked_yaml))
}
}
impl<'r> TryFrom<&AnnotatedMapping<'r, MarkedYaml<'r>>> for Subschema {
type Error = Error;
fn try_from(mapping: &AnnotatedMapping<'r, MarkedYaml<'r>>) -> crate::Result<Self> {
debug!(
"[Subschema#try_from] mapping has {} keys",
mapping.keys().len()
);
for key in mapping.keys() {
debug!("[Subschema#try_from] key: {:?}", key.data);
}
let metadata_and_annotations = MetadataAndAnnotations::try_from(mapping)?;
debug!("[Subschema#try_from] metadata_and_annotations: {metadata_and_annotations}");
let defs: Option<LinkedHashMap<String, YamlSchema>> = mapping
.get(&MarkedYaml::value_from_str("$defs"))
.map(|x| {
debug!("[Subschema#try_from] x: {}", format_yaml_data(&x.data));
debug!("[Subschema#try_from] Trying to load `$defs` as LinkedHashMap<String, YamlSchema>");
try_load_defs(x)
})
.transpose()?;
let reference: Option<Reference> = mapping
.get(&MarkedYaml::value_from_str("$ref"))
.map(|_| {
debug!("[Subschema#try_from] Trying to load `$ref` as Reference");
mapping.try_into()
})
.transpose()?;
let any_of: Option<AnyOfSchema> = mapping
.get(&MarkedYaml::value_from_str("anyOf"))
.map(|_| {
debug!("[Subschema#try_from] Trying to load `anyOf` as AnyOfSchema");
mapping.try_into()
})
.transpose()?;
let all_of: Option<AllOfSchema> = mapping
.get(&MarkedYaml::value_from_str("allOf"))
.map(|_| {
debug!("[Subschema#try_from] Trying to load `allOf` as AllOfSchema");
mapping.try_into()
})
.transpose()?;
let one_of: Option<OneOfSchema> = mapping
.get(&MarkedYaml::value_from_str("oneOf"))
.map(|_| {
debug!("[Subschema#try_from] Trying to load `oneOf` as OneOfSchema");
mapping.try_into()
})
.transpose()?;
let not: Option<NotSchema> = mapping
.get(&MarkedYaml::value_from_str("not"))
.map(|_| {
debug!("[Subschema#try_from] Trying to load `not` as NotSchema");
mapping.try_into()
})
.transpose()?;
let if_then_else: Option<IfThenElseSchema> = mapping
.get(&MarkedYaml::value_from_str("if"))
.map(|_| {
debug!(
"[Subschema#try_from] Trying to load `if`/`then`/`else` as IfThenElseSchema"
);
IfThenElseSchema::try_from(mapping)
})
.transpose()?;
let mut r#const: Option<ConstValue> = None;
if let Some(value) = mapping.get(&MarkedYaml::value_from_str("const")) {
r#const = Some(ConstValue::try_from(value)?);
}
let mut r#enum: Option<EnumSchema> = None;
if let Some(value) = mapping.get(&MarkedYaml::value_from_str("enum")) {
r#enum = Some(value.try_into()?);
}
let mut r#type: SchemaType = SchemaType::None;
if let Some(type_value) = mapping.get(&MarkedYaml::value_from_str("type")) {
match &type_value.data {
YamlData::Value(Scalar::Null) => {
r#type = SchemaType::new("null");
}
YamlData::Value(Scalar::String(s)) => r#type = SchemaType::new(s.as_ref()),
YamlData::Sequence(values) => {
r#type = SchemaType::Multiple(
values
.iter()
.map(|marked_yaml| {
marked_yaml_to_string(marked_yaml, "type must be a string")
})
.collect::<Result<Vec<String>>>()?,
)
}
_ => {
return Err(schema_loading_error!(
"[Subschema#try_from] Expected a string or sequence for `type`, but got: {:?}",
type_value.data
));
}
}
}
let mut array_schema = None;
let mut integer_schema = None;
let mut number_schema = None;
let mut object_schema = None;
let mut string_schema = None;
let types: Vec<&str> = match r#type {
SchemaType::None => vec![],
SchemaType::Single(ref s) => vec![s],
SchemaType::Multiple(ref values) => values.iter().map(|s| s.as_ref()).collect(),
};
for s in types {
match s {
"array" => {
debug!("[Subschema#try_from] Instantiating array schema");
array_schema = ArraySchema::try_from(mapping).map(Some)?;
}
"boolean" => {}
"integer" => {
debug!("[Subschema#try_from] Instantiating integer schema");
integer_schema = IntegerSchema::try_from(mapping).map(Some)?;
}
"number" => {
debug!("[Subschema#try_from] Instantiating number schema");
number_schema = NumberSchema::try_from(mapping).map(Some)?;
}
"object" => {
debug!("[Subschema#try_from] Instantiating object schema");
object_schema = ObjectSchema::try_from(mapping).map(Some)?;
}
"string" => {
debug!("[Subschema#try_from] Instantiating string schema");
string_schema = StringSchema::try_from(mapping).map(Some)?;
}
"null" => (),
_ => {
return Err(unsupported_type!(
"Expected type: string, number, integer, object, array, boolean, or null, but got: {}",
s
));
}
}
}
if r#type.is_none() && mapping.contains_key(&MarkedYaml::value_from_str("properties")) {
r#type = SchemaType::new("object");
object_schema = ObjectSchema::try_from(mapping).map(Some)?;
}
if r#type.is_none()
&& (mapping.contains_key(&MarkedYaml::value_from_str("pattern"))
|| mapping.contains_key(&MarkedYaml::value_from_str("minLength"))
|| mapping.contains_key(&MarkedYaml::value_from_str("maxLength")))
{
r#type = SchemaType::new("string");
string_schema = StringSchema::try_from(mapping).map(Some)?;
}
let unevaluated_properties = mapping
.get(&MarkedYaml::value_from_str("unevaluatedProperties"))
.map(load_boolean_or_schema_marked)
.transpose()?;
let unevaluated_items = mapping
.get(&MarkedYaml::value_from_str("unevaluatedItems"))
.map(load_boolean_or_schema_marked)
.transpose()?;
debug!("[Subschema#try_from] array_schema: {array_schema:?}");
debug!("[Subschema#try_from] integer_schema: {integer_schema:?}");
debug!("[Subschema#try_from] number_schema: {number_schema:?}");
debug!("[Subschema#try_from] object_schema: {object_schema:?}");
debug!("[Subschema#try_from] string_schema: {string_schema:?}");
Ok(Self {
metadata_and_annotations,
defs,
r#ref: reference,
any_of,
all_of,
one_of,
not,
if_then_else,
r#type,
r#const,
r#enum,
array_schema,
integer_schema,
number_schema,
object_schema,
string_schema,
unevaluated_properties,
unevaluated_items,
anchor: None,
})
}
}
impl Display for Subschema {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{{")?;
if !self.metadata_and_annotations.is_empty() {
write!(f, " ")?;
self.metadata_and_annotations.fmt(f)?;
write!(f, " ")?;
}
if !self.r#type.is_none() {
write!(f, "type: ")?;
self.r#type.fmt(f)?;
}
if let Some(r#ref) = &self.r#ref {
write!(f, "$ref: ")?;
r#ref.fmt(f)?;
}
if let Some(defs) = &self.defs {
write!(f, "$defs: {}", format_linked_hash_map(defs))?;
}
if let Some(any_of) = &self.any_of {
write!(f, "anyOf: ")?;
any_of.fmt(f)?;
}
if let Some(all_of) = &self.all_of {
write!(f, "allOf: ")?;
all_of.fmt(f)?;
}
if let Some(one_of) = &self.one_of {
write!(f, "oneOf: ")?;
one_of.fmt(f)?;
}
if let Some(not) = &self.not {
write!(f, "not: ")?;
not.fmt(f)?;
}
if let Some(ite) = &self.if_then_else {
write!(f, "if/then/else: {ite}")?;
}
write!(f, "}}")?;
Ok(())
}
}
impl Validator for Subschema {
fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> crate::Result<()> {
debug!("[Subschema] self: {self}");
debug!(
"[Subschema] Validating value: {}",
format_yaml_data(&value.data)
);
if let Some(reference) = &self.r#ref {
debug!("[Subschema] Reference found: {reference}");
let ref_name = &reference.ref_name;
if let Some(root_schema) = context.root_schema {
if let Some(ref_path) = ref_name.strip_prefix("#") {
if context.is_resolving_ref(ref_name, value) {
context.add_error(value, format!("Circular $ref detected: {ref_name}"));
return Ok(());
}
let pointer = jsonptr::Pointer::parse(ref_path)?;
debug!("[Subschema] Pointer: {pointer}");
let schema = root_schema.resolve(pointer);
if let Some(schema) = schema {
debug!("[Subschema] Found {ref_path}: {schema}");
context.begin_resolving_ref(ref_name, value);
let result = schema.validate(context, value);
context.end_resolving_ref(ref_name, value);
result?;
} else {
error!("[Subschema] Cannot find definition: {ref_path}");
context.add_error(value, format!("Schema {ref_path} not found"));
}
} else {
let ref_uri = RefUri::parse(ref_name);
let resolved_url = if ref_uri.is_absolute() {
let mut url = url::Url::parse(ref_uri.base_ref()).map_err(|e| {
generic_error!("Failed to parse absolute $ref URI {}: {}", ref_name, e)
})?;
if let Some(frag) = ref_uri.fragment() {
url.set_fragment(Some(frag));
}
url
} else {
let base = root_schema.base_uri.as_ref().ok_or_else(|| {
generic_error!(
"Relative $ref requires schema to be loaded from a file or URL. Found: {}",
ref_name
)
})?;
ref_uri.resolve_against(base)?
};
let ref_key = resolved_url.to_string();
if context.is_resolving_ref(&ref_key, value) {
context.add_error(value, format!("Circular $ref detected: {ref_name}"));
return Ok(());
}
let doc_url = {
let mut u = resolved_url.clone();
u.set_fragment(None);
u.to_string()
};
let fragment = resolved_url.fragment().and_then(|f| {
let s = if f.starts_with('/') {
f.to_string()
} else {
format!("/{f}")
};
if s.is_empty() || s == "/" {
None
} else {
Some(s)
}
});
{
let mut schemas = context.schemas.borrow_mut();
if !schemas.contains_key(&doc_url) {
let loaded = load_external_schema(&doc_url)?;
let schema_rc = Rc::new(loaded);
let key = schema_rc.cache_key(&doc_url);
schemas.insert(key.clone(), Rc::clone(&schema_rc));
if key != doc_url {
schemas.insert(doc_url.clone(), schema_rc);
}
}
}
let schemas = context.schemas.borrow();
let schema = schemas.get(&doc_url).ok_or_else(|| {
generic_error!("Schema {doc_url} not in cache after load")
})?;
let pointer_opt = fragment
.as_ref()
.map(|frag| jsonptr::Pointer::parse(frag))
.transpose()?;
let target = match &pointer_opt {
Some(pointer) => schema.resolve(pointer),
None => Some(&schema.schema),
};
if let Some(target) = target {
context.begin_resolving_ref(&ref_key, value);
let result = target.validate(context, value);
context.end_resolving_ref(&ref_key, value);
result?;
} else {
error!("[Subschema] Cannot find definition: {:?}", fragment);
context.add_error(
value,
format!("Schema {:?} not found in {doc_url}", fragment),
);
}
}
return Ok(());
} else {
return Err(generic_error!(
"Subschema has a reference, but no root schema was provided!"
));
}
}
let ctx = Self::validation_context_for_instance(context, value);
if let Some(any_of) = &self.any_of {
debug!("[Subschema] Validating anyOf schema: {any_of:?}");
any_of.validate(&ctx, value)?;
}
if let Some(all_of) = &self.all_of {
debug!("[Subschema] Validating allOf schema: {all_of:?}");
all_of.validate(&ctx, value)?;
}
if let Some(one_of) = &self.one_of {
debug!("[Subschema] Validating oneOf schema: {one_of:?}");
one_of.validate(&ctx, value)?;
}
if let Some(not) = &self.not {
debug!("[Subschema] Validating not schema: {not:?}");
not.validate(&ctx, value)?;
}
if let Some(if_then_else) = &self.if_then_else {
debug!("[Subschema] Validating if/then/else: {if_then_else:?}");
if_then_else.validate(&ctx, value)?;
}
match &self.r#type {
SchemaType::None => (),
SchemaType::Single(s) => self.validate_by_type(&ctx, s.as_ref(), value)?,
SchemaType::Multiple(values) => {
debug!(
"[Subschema] Validating multiple types: {}",
values.join(", ")
);
let mut any_matched = false;
for s in values {
let sub_context = ctx.get_sub_context();
self.validate_by_type(&sub_context, s.as_ref(), value)?;
if !sub_context.has_errors() {
any_matched = true;
break;
}
}
if !any_matched {
ctx.add_error(
value,
format!("None of type: [{}] matched", values.join(", ")),
);
}
}
}
if let Some(r#const) = &self.r#const
&& !r#const.accepts(value)
{
ctx.add_error(
value,
format!(
"Expected const: {:#?}, but got: {}",
r#const,
format_yaml_data(&value.data)
),
);
}
if let Some(r#enum) = &self.r#enum {
debug!("[Subschema] Validating enum schema: {}", r#enum);
r#enum.validate(&ctx, value)?;
}
self.apply_unevaluated(&ctx, value)?;
Ok(())
}
}
impl Subschema {
fn validation_context_for_instance<'r>(base: &Context<'r>, value: &MarkedYaml) -> Context<'r> {
match &value.data {
YamlData::Mapping(_) => {
let oe = base.object_evaluated.clone().unwrap_or_default();
base.with_object_evaluated(Some(oe))
}
YamlData::Sequence(_) => {
let arr = base
.array_unevaluated
.clone()
.unwrap_or_else(ArrayUnevaluatedAnnotations::new_shared);
base.with_array_unevaluated(Some(arr))
}
_ => base
.with_object_evaluated(base.object_evaluated.clone())
.with_array_unevaluated(base.array_unevaluated.clone()),
}
}
fn apply_unevaluated(&self, ctx: &Context, value: &MarkedYaml) -> Result<()> {
if let YamlData::Mapping(mapping) = &value.data
&& let Some(u) = &self.unevaluated_properties
{
let evaluated: HashSet<String> = ctx
.object_evaluated
.as_ref()
.map(|o| o.snapshot())
.unwrap_or_default();
for (k, v) in mapping.iter() {
let key_string = match &k.data {
YamlData::Value(scalar) => scalar_to_string(scalar),
_ => {
return Err(expected_scalar!(
"[{}] Expected a scalar object key, got: {:?}",
format_marker(&k.span.start),
k.data
));
}
};
if key_string == "$schema" {
continue;
}
if evaluated.contains(&key_string) {
continue;
}
let prop_ctx = ctx.append_path(&key_string);
match u {
BooleanOrSchema::Boolean(false) => {
ctx.add_error(
v,
format!("Unevaluated property '{key_string}' is not allowed!"),
);
}
BooleanOrSchema::Boolean(true) => {}
BooleanOrSchema::Schema(s) => {
s.validate(&prop_ctx, v)?;
}
}
}
}
if let YamlData::Sequence(seq) = &value.data
&& let Some(u) = &self.unevaluated_items
{
let ann = ctx
.array_unevaluated
.as_ref()
.map(|c| c.borrow().clone())
.unwrap_or_default();
if ann.full_coverage {
return Ok(());
}
let indices = ann.indices_requiring_unevaluated(seq.len());
let err_before = ctx.errors.borrow().len();
for i in indices.iter().copied() {
let item = &seq[i];
let item_ctx = ctx.append_path(i.to_string());
match u {
BooleanOrSchema::Boolean(false) => {
ctx.add_error(
item,
format!("Unevaluated array item at index {i} is not allowed!"),
);
}
BooleanOrSchema::Boolean(true) => {}
BooleanOrSchema::Schema(s) => {
s.validate(&item_ctx, item)?;
}
}
}
if ctx.errors.borrow().len() == err_before
&& !indices.is_empty()
&& let Some(cell) = &ctx.array_unevaluated
{
let mut a = cell.borrow_mut();
a.saw_relevant = true;
a.full_coverage = true;
}
}
Ok(())
}
fn validate_by_type(
&self,
context: &Context,
r#type: &str,
value: &saphyr::MarkedYaml,
) -> Result<()> {
debug!("[Subschema#validate_by_type] r#type: {}", r#type);
match r#type {
"array" => {
if let Some(array_schema) = &self.array_schema {
debug!("[Subschema] Validating array schema: {array_schema:?}");
array_schema.validate(context, value)?;
} else {
error!("[Subschema#validate_by_type] No array schema found");
context.add_error(value, format!("No array schema found for type: {}", r#type));
}
}
"boolean" => {
if !matches!(&value.data, YamlData::Value(Scalar::Boolean(_))) {
context.add_error(
value,
format!(
"Expected boolean, but got: {}",
format_yaml_data(&value.data)
),
);
}
}
"null" => {
if !matches!(&value.data, YamlData::Value(Scalar::Null)) {
context.add_error(
value,
format!("Expected null, but got: {}", format_yaml_data(&value.data)),
);
}
}
"string" => {
if let Some(string_schema) = &self.string_schema {
debug!("[Subschema] Validating string schema: {string_schema:?}");
string_schema.validate(context, value)?;
} else {
error!("[Subschema#validate_by_type] No string schema found");
context.add_error(
value,
format!("No string schema found for type: {}", r#type),
);
}
}
"number" => {
if let Some(number_schema) = &self.number_schema {
debug!("[Subschema] Validating number schema: {number_schema:?}");
number_schema.validate(context, value)?;
} else {
error!("[Subschema#validate_by_type] No number schema found");
context.add_error(
value,
format!("No number schema found for type: {}", r#type),
);
}
}
"integer" => {
if let Some(integer_schema) = &self.integer_schema {
debug!("[Subschema] Validating integer schema: {integer_schema:?}");
integer_schema.validate(context, value)?;
} else {
error!("[Subschema#validate_by_type] No integer schema found");
context.add_error(
value,
format!("No integer schema found for type: {}", r#type),
);
}
}
"object" => {
if let Some(object_schema) = &self.object_schema {
debug!("[Subschema] Validating object schema: {object_schema:?}");
object_schema.validate(context, value)?;
} else {
error!("[Subschema#validate_by_type] No object schema found");
context.add_error(
value,
format!("No object schema found for type: {}", r#type),
);
}
}
_ => {
error!("[Subschema#validate_by_type] Unsupported type: {}", r#type);
context.add_error(value, format!("Unsupported type: {}", r#type));
}
}
Ok(())
}
}
#[derive(Debug, Default, PartialEq)]
pub struct MetadataAndAnnotations {
pub id: Option<String>,
pub schema: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
}
impl MetadataAndAnnotations {
pub fn is_empty(&self) -> bool {
self.id.is_none()
&& self.schema.is_none()
&& self.title.is_none()
&& self.description.is_none()
}
}
impl std::fmt::Display for MetadataAndAnnotations {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{{")?;
if !self.is_empty() {
write!(f, " ")?;
if let Some(id) = &self.id {
write!(f, "id: {id}, ")?;
}
if let Some(schema) = &self.schema {
write!(f, "schema: {schema}, ")?;
}
if let Some(title) = &self.title {
write!(f, "title: {title}, ")?;
}
if let Some(description) = &self.description {
write!(f, "description: {description}, ")?;
}
write!(f, " ")?;
}
write!(f, "}}")?;
Ok(())
}
}
impl TryFrom<&AnnotatedMapping<'_, MarkedYaml<'_>>> for MetadataAndAnnotations {
type Error = Error;
fn try_from(mapping: &AnnotatedMapping<'_, MarkedYaml<'_>>) -> crate::Result<Self> {
let mut metadata_and_annotations = MetadataAndAnnotations::default();
for (key, value) in mapping.iter() {
match &key.data {
YamlData::Value(Scalar::String(s)) => match s.as_ref() {
"$id" => {
metadata_and_annotations.id =
Some(marked_yaml_to_string(value, "$id must be a string")?);
}
"$schema" => {
metadata_and_annotations.schema =
Some(marked_yaml_to_string(value, "$schema must be a string")?);
}
"title" => {
metadata_and_annotations.title =
Some(marked_yaml_to_string(value, "title must be a string")?);
}
"description" => {
metadata_and_annotations.description = Some(marked_yaml_to_string(
value,
"description must be a string",
)?);
}
_ => {
debug!("[MetadataAndAnnotations#try_from] Unknown key: {s}");
}
},
_ => {
debug!("[MetadataAndAnnotations#try_from] Unsupported key data: {key:?}");
}
}
}
Ok(metadata_and_annotations)
}
}
#[cfg(test)]
mod tests {
use saphyr::LoadableYamlNode;
use crate::engine;
use crate::loader;
use super::*;
#[test]
fn test_type_boolean() {
let yaml = r#"
type: boolean
"#;
let doc = MarkedYaml::load_from_str(yaml).expect("Failed to load YAML");
let marked_yaml = doc.first().unwrap();
let yaml_schema = YamlSchema::try_from(marked_yaml).unwrap();
let YamlSchema::Subschema(subschema) = yaml_schema else {
panic!("Expected a subschema");
};
assert!(!subschema.r#type.is_none());
assert!(subschema.r#type.is_single());
let SchemaType::Single(type_value) = subschema.r#type else {
panic!("Expected a single type");
};
assert_eq!(type_value, "boolean");
}
#[test]
fn test_metadata_and_annotations_try_from() {
let yaml = r#"
$id: http://example.com/schema
$schema: http://example.com/schema
title: Example Schema
description: This is an example schema
"#;
let doc = MarkedYaml::load_from_str(yaml).expect("Failed to load YAML");
let marked_yaml = doc.first().unwrap();
assert!(marked_yaml.data.is_mapping());
let YamlData::Mapping(mapping) = &marked_yaml.data else {
panic!("Expected a mapping");
};
let metadata_and_annotations = MetadataAndAnnotations::try_from(mapping).unwrap();
assert_eq!(
metadata_and_annotations.id,
Some("http://example.com/schema".to_string())
);
assert_eq!(
metadata_and_annotations.schema,
Some("http://example.com/schema".to_string())
);
assert_eq!(
metadata_and_annotations.title,
Some("Example Schema".to_string())
);
assert_eq!(
metadata_and_annotations.description,
Some("This is an example schema".to_string())
);
}
#[test]
fn test_yaml_schema_with_multiple_types() {
let yaml = r#"
type:
- boolean
- number
- integer
- string
"#;
let doc = MarkedYaml::load_from_str(yaml).expect("Failed to load YAML");
let marked_yaml = doc.first().unwrap();
let yaml_schema = YamlSchema::try_from(marked_yaml).unwrap();
let YamlSchema::Subschema(subschema) = yaml_schema else {
panic!("Expected a subschema");
};
assert!(!subschema.r#type.is_none());
assert!(subschema.r#type.is_multiple());
let SchemaType::Multiple(type_values) = subschema.r#type else {
panic!("Expected a multiple type");
};
assert_eq!(type_values, vec!["boolean", "number", "integer", "string"]);
}
#[test]
fn test_multiple_types() {
let schema = r#"
type:
- string
- number
"#;
let schema = loader::load_from_str(schema).unwrap();
let s = "I'm a string";
let docs = MarkedYaml::load_from_str(s).unwrap();
let value = docs.first().unwrap();
let context = Context::default();
let result = schema.validate(&context, value);
assert!(result.is_ok());
assert!(!context.has_errors());
let s = "42";
let docs = MarkedYaml::load_from_str(s).unwrap();
let value = docs.first().unwrap();
let context = Context::default();
let result = schema.validate(&context, value);
assert!(result.is_ok());
assert!(!context.has_errors());
let s = "null";
let docs = MarkedYaml::load_from_str(s).unwrap();
let value = docs.first().unwrap();
let context = Context::default();
let result = schema.validate(&context, value);
assert!(result.is_ok());
assert!(context.has_errors());
let errors = context.errors.borrow();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].error, "None of type: [string, number] matched");
}
#[test]
fn properties_without_type_infers_object_and_validates() {
let yaml = r#"
properties:
foo:
type: string
required:
- foo
"#;
let root = loader::load_from_str(yaml).unwrap();
let YamlSchema::Subschema(sub) = &root.schema else {
panic!("expected subschema");
};
assert!(
sub.r#type.is_or_contains("object"),
"expected inferred type object"
);
assert!(sub.object_schema.is_some());
let ok = engine::Engine::evaluate(&root, "foo: bar", false).unwrap();
assert!(!ok.has_errors());
let bad = engine::Engine::evaluate(&root, "other: x", false).unwrap();
assert!(bad.has_errors());
}
#[test]
fn test_object_schema_with_const_property() {
let schema = r#"
type: object
properties:
const:
description: A scalar value that must match the value
type:
- string
- integer
- number
- boolean
"#;
let schema = loader::load_from_str(schema).expect("Failed to load schema");
let docs = MarkedYaml::load_from_str(
r#"
const: "I'm a string"
"#,
)
.unwrap();
let value = docs.first().unwrap();
let context = Context::default();
let result = schema.validate(&context, value);
assert!(result.is_ok());
assert!(!context.has_errors());
}
#[test]
fn unevaluated_properties_all_of_extra_key_rejected() {
let root = loader::load_from_str(
r#"
allOf:
- properties:
a:
type: string
- unevaluatedProperties: false
"#,
)
.unwrap();
let ok = engine::Engine::evaluate(&root, "a: ok", false).unwrap();
assert!(!ok.has_errors());
let bad = engine::Engine::evaluate(&root, "a: ok\nb: no", false).unwrap();
assert!(bad.has_errors());
}
}