Skip to main content

yaml_schema/
lib.rs

1//! yaml-schema is a library for validating YAML data against a JSON Schema.
2
3use hashlink::LinkedHashMap;
4use jsonptr::Pointer;
5use log::debug;
6use saphyr::MarkedYaml;
7use saphyr::Scalar;
8use saphyr::YamlData;
9
10#[macro_use]
11pub mod error;
12pub mod engine;
13pub mod loader;
14pub mod reference;
15pub mod schemas;
16pub mod utils;
17pub mod validation;
18
19pub use engine::Engine;
20pub use error::Error;
21pub use reference::Reference;
22pub use schemas::YamlSchema;
23pub use validation::Context;
24pub use validation::Validator;
25
26use utils::format_marker;
27
28use crate::loader::marked_yaml_to_string;
29
30// Returns the library version, which reflects the crate version
31pub fn version() -> String {
32    clap::crate_version!().to_string()
33}
34
35// Alias for std::result::Result<T, yaml_schema::Error>
36pub type Result<T> = std::result::Result<T, Error>;
37
38/// A RootSchema represents the root document in a schema document, and includes additional
39/// fields such as `$schema` that are not allowed in subschemas. It also provides a way to
40/// resolve references to other schemas.
41#[derive(Debug, PartialEq)]
42pub struct RootSchema<'r> {
43    pub meta_schema: Option<String>,
44    pub schema: YamlSchema<'r>,
45}
46
47impl<'r> RootSchema<'r> {
48    /// Create an empty RootSchema
49    pub fn empty() -> Self {
50        Self {
51            meta_schema: None,
52            schema: YamlSchema::Empty,
53        }
54    }
55
56    /// Create a new RootSchema with a given schema
57    pub fn new(schema: YamlSchema<'r>) -> Self {
58        Self {
59            meta_schema: None,
60            schema,
61        }
62    }
63
64    /// Resolve a JSON Pointer to an element in the schema.
65    pub fn resolve(&self, pointer: &Pointer) -> Option<&YamlSchema<'_>> {
66        let components = pointer.components().collect::<Vec<_>>();
67        debug!("[RootSchema#resolve] components: {components:?}");
68        components.first().and_then(|component| {
69            debug!("[RootSchema#resolve] component: {component:?}");
70            match component {
71                jsonptr::Component::Root => {
72                    let components = &components[1..];
73                    components.first().and_then(|component| {
74                        debug!("[RootSchema#resolve] component: {component:?}");
75                        match component {
76                            jsonptr::Component::Root => unimplemented!(),
77                            jsonptr::Component::Token(token) => {
78                                self.schema.resolve(Some(token), &components[1..])
79                            }
80                        }
81                    })
82                }
83                jsonptr::Component::Token(token) => {
84                    self.schema.resolve(Some(token), &components[1..])
85                }
86            }
87        })
88    }
89}
90
91impl<'r> TryFrom<&MarkedYaml<'r>> for RootSchema<'r> {
92    type Error = crate::Error;
93
94    fn try_from(marked_yaml: &MarkedYaml<'r>) -> Result<Self> {
95        match &marked_yaml.data {
96            YamlData::Value(scalar) => match scalar {
97                Scalar::Boolean(r#bool) => Ok(Self {
98                    meta_schema: None,
99                    schema: YamlSchema::<'r>::BooleanLiteral(*r#bool),
100                }),
101                Scalar::Null => Ok(RootSchema {
102                    meta_schema: None,
103                    schema: YamlSchema::<'r>::Null,
104                }),
105                _ => Err(generic_error!(
106                    "[loader#load_from_doc] Don't know how to a handle scalar: {:?}",
107                    scalar
108                )),
109            },
110            YamlData::Mapping(mapping) => {
111                debug!(
112                    "[loader#load_from_doc] Found mapping, trying to load as RootSchema: {mapping:?}"
113                );
114                let meta_schema = mapping
115                    .get(&MarkedYaml::value_from_str("$schema"))
116                    .map(|my| marked_yaml_to_string(my, "$schema must be a string"))
117                    .transpose()?;
118
119                let schema = YamlSchema::try_from(marked_yaml)?;
120                Ok(RootSchema {
121                    meta_schema,
122                    schema,
123                })
124            }
125            _ => Err(generic_error!(
126                "[loader#load_from_doc] Don't know how to load: {:?}",
127                marked_yaml
128            )),
129        }
130    }
131}
132
133impl Validator for RootSchema<'_> {
134    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> Result<()> {
135        self.schema.validate(context, value)
136    }
137}
138
139/// A Number is either an integer or a float
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub enum Number {
142    Integer(i64),
143    Float(f64),
144}
145
146impl Number {
147    /// Create a new integer Number
148    pub fn integer(value: i64) -> Number {
149        Number::Integer(value)
150    }
151
152    /// Create a new float Number
153    pub fn float(value: f64) -> Number {
154        Number::Float(value)
155    }
156
157    pub fn to_f64(self) -> f64 {
158        match self {
159            Number::Integer(i) => i as f64,
160            Number::Float(f) => f,
161        }
162    }
163
164    pub fn is_multiple_of(self, divisor: Number) -> bool {
165        match (self, divisor) {
166            (Number::Integer(a), Number::Integer(b)) => b != 0 && a % b == 0,
167            _ => {
168                let d = divisor.to_f64();
169                d != 0.0 && self.to_f64() % d == 0.0
170            }
171        }
172    }
173}
174
175impl PartialOrd for Number {
176    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
177        match (self, other) {
178            (Number::Integer(a), Number::Integer(b)) => a.partial_cmp(b),
179            _ => self.to_f64().partial_cmp(&other.to_f64()),
180        }
181    }
182}
183
184impl TryFrom<&MarkedYaml<'_>> for Number {
185    type Error = Error;
186    fn try_from(value: &MarkedYaml) -> Result<Number> {
187        if let YamlData::Value(scalar) = &value.data {
188            match scalar {
189                Scalar::Integer(i) => Ok(Number::integer(*i)),
190                Scalar::FloatingPoint(o) => Ok(Number::float(o.into_inner())),
191                _ => Err(generic_error!(
192                    "{} Expected type: integer or float, but got: {:?}",
193                    format_marker(&value.span.start),
194                    value
195                )),
196            }
197        } else {
198            Err(generic_error!(
199                "{} Expected scalar, but got: {:?}",
200                format_marker(&value.span.start),
201                value
202            ))
203        }
204    }
205}
206
207impl std::fmt::Display for Number {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        match self {
210            Number::Integer(v) => write!(f, "{v}"),
211            Number::Float(v) => write!(f, "{v}"),
212        }
213    }
214}
215
216/// A ConstValue represents a constant value for the `const` keyword.
217/// Per JSON Schema, `const` can be any JSON value: null, boolean, number,
218/// string, array, or object.
219#[derive(Debug, PartialEq)]
220pub enum ConstValue {
221    Null,
222    Boolean(bool),
223    Number(Number),
224    String(String),
225    Array(Vec<ConstValue>),
226    Object(LinkedHashMap<String, ConstValue>),
227}
228
229impl ConstValue {
230    pub fn null() -> ConstValue {
231        ConstValue::Null
232    }
233    pub fn boolean(value: bool) -> ConstValue {
234        ConstValue::Boolean(value)
235    }
236    pub fn integer(value: i64) -> ConstValue {
237        ConstValue::Number(Number::integer(value))
238    }
239    pub fn float(value: f64) -> ConstValue {
240        ConstValue::Number(Number::float(value))
241    }
242    pub fn string<V: Into<String>>(value: V) -> ConstValue {
243        ConstValue::String(value.into())
244    }
245
246    pub fn accepts(&self, value: &saphyr::MarkedYaml) -> bool {
247        match self {
248            ConstValue::Null => matches!(&value.data, YamlData::Value(Scalar::Null)),
249            ConstValue::Boolean(expected) => {
250                matches!(&value.data, YamlData::Value(Scalar::Boolean(actual)) if *expected == *actual)
251            }
252            ConstValue::Number(number) => match (number, &value.data) {
253                (Number::Integer(expected), YamlData::Value(Scalar::Integer(actual))) => {
254                    *actual == *expected
255                }
256                (Number::Float(expected), YamlData::Value(Scalar::FloatingPoint(of))) => {
257                    of.into_inner() == *expected
258                }
259                _ => false,
260            },
261            ConstValue::String(expected) => {
262                matches!(&value.data, YamlData::Value(Scalar::String(actual)) if expected == actual.as_ref())
263            }
264            ConstValue::Array(expected) => {
265                if let YamlData::Sequence(actual) = &value.data {
266                    expected.len() == actual.len()
267                        && expected
268                            .iter()
269                            .zip(actual.iter())
270                            .all(|(exp, act)| exp.accepts(act))
271                } else {
272                    false
273                }
274            }
275            ConstValue::Object(expected) => {
276                if let YamlData::Mapping(actual) = &value.data {
277                    expected.len() == actual.len()
278                        && expected.iter().all(|(key, exp_val)| {
279                            let key_yaml = MarkedYaml::value_from_str(key);
280                            actual
281                                .get(&key_yaml)
282                                .is_some_and(|act_yaml| exp_val.accepts(act_yaml))
283                        })
284                } else {
285                    false
286                }
287            }
288        }
289    }
290}
291
292impl TryFrom<&Scalar<'_>> for ConstValue {
293    type Error = crate::Error;
294
295    fn try_from(scalar: &Scalar) -> std::result::Result<ConstValue, Self::Error> {
296        match scalar {
297            Scalar::Null => Ok(ConstValue::Null),
298            Scalar::Boolean(b) => Ok(ConstValue::Boolean(*b)),
299            Scalar::Integer(i) => Ok(ConstValue::Number(Number::integer(*i))),
300            Scalar::FloatingPoint(o) => Ok(ConstValue::Number(Number::float(o.into_inner()))),
301            Scalar::String(s) => Ok(ConstValue::String(s.to_string())),
302        }
303    }
304}
305
306impl<'a> TryFrom<&YamlData<'a, MarkedYaml<'a>>> for ConstValue {
307    type Error = crate::Error;
308
309    fn try_from(value: &YamlData<'a, MarkedYaml<'a>>) -> Result<Self> {
310        match value {
311            YamlData::Value(scalar) => scalar.try_into(),
312            YamlData::Sequence(seq) => {
313                let arr = seq
314                    .iter()
315                    .map(|item| item.try_into())
316                    .collect::<Result<Vec<_>>>()?;
317                Ok(ConstValue::Array(arr))
318            }
319            YamlData::Mapping(mapping) => {
320                let mut obj = LinkedHashMap::new();
321                for (key, val) in mapping.iter() {
322                    let key_str = marked_yaml_to_string(key, "const object key must be a string")?;
323                    let val_cv: ConstValue = val.try_into()?;
324                    obj.insert(key_str, val_cv);
325                }
326                Ok(ConstValue::Object(obj))
327            }
328            YamlData::Tagged(_, inner) => (&inner.data).try_into(),
329            YamlData::Representation(_, _, _) | YamlData::Alias(_) | YamlData::BadValue => Err(
330                generic_error!("Unsupported YamlData variant for const: {:?}", value),
331            ),
332        }
333    }
334}
335
336impl<'a> TryFrom<&MarkedYaml<'a>> for ConstValue {
337    type Error = crate::Error;
338    fn try_from(value: &MarkedYaml<'a>) -> Result<ConstValue> {
339        (&value.data).try_into()
340    }
341}
342
343impl std::fmt::Display for ConstValue {
344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        match self {
346            ConstValue::Boolean(b) => write!(f, "{b} (bool)"),
347            ConstValue::Null => write!(f, "null"),
348            ConstValue::Number(n) => write!(f, "{n} (number)"),
349            ConstValue::String(s) => write!(f, "\"{s}\""),
350            ConstValue::Array(arr) => {
351                write!(f, "[")?;
352                for (i, v) in arr.iter().enumerate() {
353                    if i > 0 {
354                        write!(f, ", ")?;
355                    }
356                    write!(f, "{v}")?;
357                }
358                write!(f, "]")
359            }
360            ConstValue::Object(obj) => {
361                write!(f, "{{")?;
362                for (i, (k, v)) in obj.iter().enumerate() {
363                    if i > 0 {
364                        write!(f, ", ")?;
365                    }
366                    write!(f, "\"{k}\": {v}")?;
367                }
368                write!(f, "}}")
369            }
370        }
371    }
372}
373
374/// Use the ctor crate to initialize the logger for tests
375#[cfg(test)]
376#[ctor::ctor]
377fn init() {
378    env_logger::builder()
379        .filter_level(log::LevelFilter::Trace)
380        .format_target(false)
381        .format_timestamp_secs()
382        .target(env_logger::Target::Stdout)
383        .init();
384}
385
386#[cfg(test)]
387mod tests {
388    use saphyr::LoadableYamlNode;
389
390    use super::*;
391    use ordered_float::OrderedFloat;
392
393    #[test]
394    fn test_const_equality() {
395        let i1 = ConstValue::integer(42);
396        let i2 = ConstValue::integer(42);
397        assert_eq!(i1, i2);
398
399        let s1 = ConstValue::string("NW");
400        let s2 = ConstValue::string("NW");
401        assert_eq!(s1, s2);
402    }
403
404    #[test]
405    #[allow(clippy::approx_constant)]
406    fn test_scalar_to_constvalue() -> Result<()> {
407        let scalars = [
408            Scalar::Null,
409            Scalar::Boolean(true),
410            Scalar::Boolean(false),
411            Scalar::Integer(42),
412            Scalar::Integer(-1),
413            Scalar::FloatingPoint(OrderedFloat::from(3.14)),
414            Scalar::String("foo".into()),
415        ];
416
417        let expected = [
418            ConstValue::Null,
419            ConstValue::Boolean(true),
420            ConstValue::Boolean(false),
421            ConstValue::Number(Number::Integer(42)),
422            ConstValue::Number(Number::Integer(-1)),
423            ConstValue::Number(Number::Float(3.14)),
424            ConstValue::String("foo".to_string()),
425        ];
426
427        for (scalar, expected) in scalars.iter().zip(expected.iter()) {
428            let actual: ConstValue = scalar.try_into()?;
429            assert_eq!(*expected, actual);
430        }
431
432        Ok(())
433    }
434
435    #[test]
436    fn test_const_value_array_try_from() -> Result<()> {
437        let docs = MarkedYaml::load_from_str("[1, 2, 3]").unwrap();
438        let cv: ConstValue = docs.first().unwrap().try_into()?;
439        assert_eq!(
440            cv,
441            ConstValue::Array(vec![
442                ConstValue::integer(1),
443                ConstValue::integer(2),
444                ConstValue::integer(3),
445            ])
446        );
447        Ok(())
448    }
449
450    #[test]
451    fn test_const_value_object_try_from() -> Result<()> {
452        let docs = MarkedYaml::load_from_str("a: 1\nb: two").unwrap();
453        let cv: ConstValue = docs.first().unwrap().try_into()?;
454        let mut expected = LinkedHashMap::new();
455        expected.insert("a".into(), ConstValue::integer(1));
456        expected.insert("b".into(), ConstValue::string("two"));
457        assert_eq!(cv, ConstValue::Object(expected));
458        Ok(())
459    }
460
461    #[test]
462    fn test_const_value_accepts_array() -> Result<()> {
463        let cv = ConstValue::Array(vec![ConstValue::integer(1), ConstValue::string("foo")]);
464        let matching = MarkedYaml::load_from_str("[1, \"foo\"]").unwrap();
465        let not_matching = MarkedYaml::load_from_str("[1, \"bar\"]").unwrap();
466        assert!(cv.accepts(matching.first().unwrap()));
467        assert!(!cv.accepts(not_matching.first().unwrap()));
468        Ok(())
469    }
470
471    #[test]
472    fn test_const_value_accepts_object() -> Result<()> {
473        let mut obj = LinkedHashMap::new();
474        obj.insert("x".into(), ConstValue::integer(42));
475        obj.insert("y".into(), ConstValue::string("hi"));
476        let cv = ConstValue::Object(obj);
477        let matching = MarkedYaml::load_from_str("x: 42\ny: hi").unwrap();
478        let not_matching = MarkedYaml::load_from_str("x: 43\ny: hi").unwrap();
479        assert!(cv.accepts(matching.first().unwrap()));
480        assert!(!cv.accepts(not_matching.first().unwrap()));
481        Ok(())
482    }
483}