Skip to main content

yaml_schema/
reference.rs

1use std::borrow::Cow;
2
3use log::debug;
4use saphyr::AnnotatedMapping;
5use saphyr::MarkedYaml;
6use saphyr::YamlData;
7
8use crate::utils::format_annotated_mapping;
9
10/// A Reference is a reference to another schema, usually one that is
11/// declared in the `$defs` section of the root schema.
12#[derive(Clone, Debug, Default, PartialEq)]
13pub struct Reference<'r> {
14    pub ref_name: Cow<'r, str>,
15}
16
17impl std::fmt::Display for Reference<'_> {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        write!(f, "$ref: {}", self.ref_name)
20    }
21}
22
23impl<'r> Reference<'r> {
24    pub fn new(ref_name: Cow<'r, str>) -> Reference<'r> {
25        Reference { ref_name }
26    }
27}
28
29impl<'r> TryFrom<&MarkedYaml<'r>> for Reference<'r> {
30    type Error = crate::Error;
31
32    fn try_from(value: &MarkedYaml<'r>) -> std::result::Result<Self, Self::Error> {
33        if let YamlData::Mapping(mapping) = &value.data {
34            Self::try_from(mapping)
35        } else {
36            Err(expected_mapping!(value))
37        }
38    }
39}
40
41impl<'r> TryFrom<&AnnotatedMapping<'r, MarkedYaml<'r>>> for Reference<'r> {
42    type Error = crate::Error;
43
44    fn try_from<'a>(
45        mapping: &AnnotatedMapping<'a, MarkedYaml<'a>>,
46    ) -> crate::Result<Reference<'a>> {
47        debug!("[Reference#try_from] {}", format_annotated_mapping(mapping));
48        let ref_key = MarkedYaml::value_from_str("$ref");
49        if let Some(ref_value) = mapping.get(&ref_key) {
50            match &ref_value.data {
51                YamlData::Value(saphyr::Scalar::String(s)) => {
52                    if !s.starts_with("#/$defs/") && !s.starts_with("#/definitions/") {
53                        return Err(generic_error!(
54                            "Only local references, starting with #/$defs/ or #/definitions/ are supported for now. Found: {}",
55                            s
56                        ));
57                    }
58                    Ok(Reference::new(s.clone()))
59                }
60                _ => Err(generic_error!(
61                    "Expected a string value for $ref, but got: {:?}",
62                    ref_value
63                )),
64            }
65        } else {
66            Err(generic_error!(
67                "No $ref key found in mapping: {}",
68                format_annotated_mapping(mapping)
69            ))
70        }
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use crate::Validator as _;
77    use crate::YamlSchema;
78    use crate::loader;
79    use saphyr::LoadableYamlNode;
80
81    #[test]
82    fn test_reference() {
83        let schema = r##"
84            $defs:
85                name:
86                    type: string
87            type: object
88            properties:
89                name:
90                    $ref: "#/$defs/name"
91        "##;
92        let root_schema = loader::load_from_str(schema).expect("Failed to load schema");
93        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
94            panic!("Expected a subschema");
95        };
96
97        // Assert that the subschema has the expected structure
98        // 1. Verify it's an object type
99        assert!(
100            subschema.r#type.is_or_contains("object"),
101            "Expected object type"
102        );
103
104        // 2. Verify $defs contains "name" definition
105        let defs = subschema
106            .defs
107            .as_ref()
108            .expect("Expected $defs to be present");
109        let name_def = defs.get("name").expect("Expected 'name' in $defs");
110
111        // 3. Verify the "name" definition is a string schema
112        let YamlSchema::Subschema(name_subschema) = name_def else {
113            panic!("Expected name definition to be a subschema");
114        };
115        assert!(
116            name_subschema.r#type.is_or_contains("string"),
117            "Expected name definition to be a string type"
118        );
119
120        // 4. Verify object_schema exists with properties
121        let object_schema = subschema
122            .object_schema
123            .as_ref()
124            .expect("Expected object_schema");
125        let properties = object_schema
126            .properties
127            .as_ref()
128            .expect("Expected properties");
129
130        // 5. Verify properties contains "name" with a reference
131        let name_property = properties.get("name").expect("Expected 'name' property");
132        let YamlSchema::Subschema(name_prop_subschema) = name_property else {
133            panic!("Expected name property to be a subschema");
134        };
135
136        // 6. Verify the name property is a reference to "#/$defs/name"
137        let ref_value = name_prop_subschema
138            .r#ref
139            .as_ref()
140            .expect("Expected $ref in name property");
141        assert_eq!(
142            ref_value.ref_name.as_ref(),
143            "#/$defs/name",
144            "Expected reference to '#/$defs/name'"
145        );
146
147        let context = crate::Context::with_root_schema(&root_schema, true);
148        let value = r##"
149            name: "John Doe"
150        "##;
151        let docs = saphyr::MarkedYaml::load_from_str(value).unwrap();
152        let value = docs.first().unwrap();
153        let result = root_schema.validate(&context, value);
154        assert!(result.is_ok());
155        assert!(!context.has_errors());
156    }
157
158    #[test]
159    fn test_json_ptr() {
160        let ptr = jsonptr::Pointer::parse("/$defs/schema").expect("Failed to parse JSON pointer");
161        let components: Vec<_> = ptr.components().collect();
162        assert!(!components.is_empty());
163    }
164
165    #[test]
166    fn test_circular_reference_direct() {
167        let schema = r##"
168            $defs:
169                a:
170                    $ref: "#/$defs/a"
171            $ref: "#/$defs/a"
172        "##;
173        let root_schema = loader::load_from_str(schema).expect("Failed to load schema");
174        let context = crate::Context::with_root_schema(&root_schema, false);
175        let docs = saphyr::MarkedYaml::load_from_str("test").unwrap();
176        let value = docs.first().unwrap();
177        let result = root_schema.validate(&context, value);
178        assert!(result.is_ok());
179        assert!(context.has_errors());
180        let errors = context.errors.borrow();
181        assert_eq!(errors.len(), 1);
182        assert!(
183            errors[0].error.contains("Circular $ref detected"),
184            "Expected circular ref error, got: {}",
185            errors[0].error
186        );
187    }
188
189    #[test]
190    fn test_circular_reference_indirect() {
191        let schema = r##"
192            $defs:
193                a:
194                    $ref: "#/$defs/b"
195                b:
196                    $ref: "#/$defs/a"
197            $ref: "#/$defs/a"
198        "##;
199        let root_schema = loader::load_from_str(schema).expect("Failed to load schema");
200        let context = crate::Context::with_root_schema(&root_schema, false);
201        let docs = saphyr::MarkedYaml::load_from_str("test").unwrap();
202        let value = docs.first().unwrap();
203        let result = root_schema.validate(&context, value);
204        assert!(result.is_ok());
205        assert!(context.has_errors());
206        let errors = context.errors.borrow();
207        assert_eq!(errors.len(), 1);
208        assert!(
209            errors[0].error.contains("Circular $ref detected"),
210            "Expected circular ref error, got: {}",
211            errors[0].error
212        );
213    }
214
215    #[test]
216    fn test_non_circular_ref_still_works() {
217        let schema = r##"
218            $defs:
219                name:
220                    type: string
221            type: object
222            properties:
223                first_name:
224                    $ref: "#/$defs/name"
225                last_name:
226                    $ref: "#/$defs/name"
227        "##;
228        let root_schema = loader::load_from_str(schema).expect("Failed to load schema");
229        let context = crate::Context::with_root_schema(&root_schema, false);
230        let value = r#"
231            first_name: "Alice"
232            last_name: "Smith"
233        "#;
234        let docs = saphyr::MarkedYaml::load_from_str(value).unwrap();
235        let value = docs.first().unwrap();
236        let result = root_schema.validate(&context, value);
237        assert!(result.is_ok());
238        assert!(
239            !context.has_errors(),
240            "Expected no errors, got: {:?}",
241            context.errors.borrow()
242        );
243    }
244}