Skip to main content

yaml_schema/
reference.rs

1use log::debug;
2use saphyr::AnnotatedMapping;
3use saphyr::MarkedYaml;
4use saphyr::YamlData;
5use url::Url;
6
7use crate::Result;
8use crate::utils::format_annotated_mapping;
9
10/// Parsed representation of a `$ref` URI for resolution.
11/// Supports same-document (#/...), relative (./other.yaml), and absolute (https://..., file:///...) references.
12#[derive(Clone, Debug, PartialEq)]
13pub struct RefUri {
14    /// The reference as given (for display/errors).
15    raw: String,
16    /// Part before # (empty for same-document).
17    base_ref: String,
18    /// JSON Pointer fragment after # (e.g. "/$defs/foo"), or None if no fragment.
19    fragment: Option<String>,
20}
21
22impl RefUri {
23    /// Parse a `$ref` string into base and fragment components.
24    pub fn parse(ref_str: &str) -> RefUri {
25        let raw = ref_str.to_string();
26        let (base_ref, fragment) = match ref_str.split_once('#') {
27            Some((base, frag)) => (base.to_string(), Some(frag.to_string())),
28            None => (ref_str.to_string(), None),
29        };
30        RefUri {
31            raw,
32            base_ref,
33            fragment,
34        }
35    }
36
37    /// Returns true if this is a same-document reference (starts with #).
38    pub fn is_same_document(&self) -> bool {
39        self.raw.starts_with('#')
40    }
41
42    /// Returns true if the base part of the reference is an absolute URI (has a scheme).
43    pub fn is_absolute(&self) -> bool {
44        Url::parse(&self.base_ref).is_ok()
45    }
46
47    /// Returns the JSON Pointer fragment (e.g. "/$defs/foo") if present.
48    pub fn fragment(&self) -> Option<&str> {
49        self.fragment.as_deref()
50    }
51
52    /// Returns the part before # (base URI or path).
53    pub fn base_ref(&self) -> &str {
54        &self.base_ref
55    }
56
57    /// Returns the raw reference string.
58    pub fn as_str(&self) -> &str {
59        &self.raw
60    }
61
62    /// Resolve this reference against a base URL (RFC 3986).
63    /// For same-document refs, returns an error (use fragment resolution instead).
64    /// For relative refs, joins with base. For absolute refs, parses and optionally appends fragment.
65    pub fn resolve_against(&self, base: &Url) -> Result<Url> {
66        if self.is_same_document() {
67            return Err(crate::generic_error!(
68                "Cannot resolve same-document ref against base URI: {}",
69                self.raw
70            ));
71        }
72        let mut resolved = base.join(&self.base_ref).map_err(|e| {
73            crate::generic_error!(
74                "Failed to resolve $ref {} against base {}: {}",
75                self.raw,
76                base,
77                e
78            )
79        })?;
80        if let Some(frag) = &self.fragment {
81            resolved.set_fragment(Some(frag));
82        }
83        Ok(resolved)
84    }
85}
86
87/// A Reference is a reference to another schema, usually one that is
88/// declared in the `$defs` section of the root schema.
89#[derive(Clone, Debug, Default, PartialEq)]
90pub struct Reference {
91    pub ref_name: String,
92}
93
94impl std::fmt::Display for Reference {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(f, "$ref: {}", self.ref_name)
97    }
98}
99
100impl Reference {
101    pub fn new(ref_name: impl Into<String>) -> Self {
102        Self {
103            ref_name: ref_name.into(),
104        }
105    }
106}
107
108impl<'r> TryFrom<&MarkedYaml<'r>> for Reference {
109    type Error = crate::Error;
110
111    fn try_from(value: &MarkedYaml<'r>) -> std::result::Result<Self, Self::Error> {
112        if let YamlData::Mapping(mapping) = &value.data {
113            Self::try_from(mapping)
114        } else {
115            Err(expected_mapping!(value))
116        }
117    }
118}
119
120impl<'r> TryFrom<&AnnotatedMapping<'r, MarkedYaml<'r>>> for Reference {
121    type Error = crate::Error;
122
123    fn try_from(mapping: &AnnotatedMapping<'r, MarkedYaml<'r>>) -> crate::Result<Self> {
124        debug!("[Reference#try_from] {}", format_annotated_mapping(mapping));
125        let ref_key = MarkedYaml::value_from_str("$ref");
126        if let Some(ref_value) = mapping.get(&ref_key) {
127            match &ref_value.data {
128                YamlData::Value(saphyr::Scalar::String(s)) => {
129                    Ok(Reference::new(s.as_ref().to_string()))
130                }
131                _ => Err(generic_error!(
132                    "Expected a string value for $ref, but got: {:?}",
133                    ref_value
134                )),
135            }
136        } else {
137            Err(generic_error!(
138                "No $ref key found in mapping: {}",
139                format_annotated_mapping(mapping)
140            ))
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use saphyr::LoadableYamlNode;
148
149    use crate::Validator as _;
150    use crate::YamlSchema;
151    use crate::loader;
152
153    use super::RefUri;
154
155    #[test]
156    fn test_reference() {
157        let schema = r##"
158            $defs:
159                name:
160                    type: string
161            type: object
162            properties:
163                name:
164                    $ref: "#/$defs/name"
165        "##;
166        let root_schema = loader::load_from_str(schema).expect("Failed to load schema");
167        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
168            panic!("Expected a subschema");
169        };
170
171        // Assert that the subschema has the expected structure
172        // 1. Verify it's an object type
173        assert!(
174            subschema.r#type.is_or_contains("object"),
175            "Expected object type"
176        );
177
178        // 2. Verify $defs contains "name" definition
179        let defs = subschema
180            .defs
181            .as_ref()
182            .expect("Expected $defs to be present");
183        let name_def = defs.get("name").expect("Expected 'name' in $defs");
184
185        // 3. Verify the "name" definition is a string schema
186        let YamlSchema::Subschema(name_subschema) = name_def else {
187            panic!("Expected name definition to be a subschema");
188        };
189        assert!(
190            name_subschema.r#type.is_or_contains("string"),
191            "Expected name definition to be a string type"
192        );
193
194        // 4. Verify object_schema exists with properties
195        let object_schema = subschema
196            .object_schema
197            .as_ref()
198            .expect("Expected object_schema");
199        let properties = object_schema
200            .properties
201            .as_ref()
202            .expect("Expected properties");
203
204        // 5. Verify properties contains "name" with a reference
205        let name_property = properties.get("name").expect("Expected 'name' property");
206        let YamlSchema::Subschema(name_prop_subschema) = name_property else {
207            panic!("Expected name property to be a subschema");
208        };
209
210        // 6. Verify the name property is a reference to "#/$defs/name"
211        let ref_value = name_prop_subschema
212            .r#ref
213            .as_ref()
214            .expect("Expected $ref in name property");
215        assert_eq!(
216            ref_value.ref_name, "#/$defs/name",
217            "Expected reference to '#/$defs/name'"
218        );
219
220        let context = crate::Context::with_root_schema(&root_schema, true);
221        let value = r##"
222            name: "John Doe"
223        "##;
224        let docs = saphyr::MarkedYaml::load_from_str(value).unwrap();
225        let value = docs.first().unwrap();
226        let result = root_schema.validate(&context, value);
227        assert!(result.is_ok());
228        assert!(!context.has_errors());
229    }
230
231    #[test]
232    fn test_json_ptr() {
233        let ptr = jsonptr::Pointer::parse("/$defs/schema").expect("Failed to parse JSON pointer");
234        let components: Vec<_> = ptr.components().collect();
235        assert!(!components.is_empty());
236    }
237
238    #[test]
239    fn test_circular_reference_direct() {
240        let schema = r##"
241            $defs:
242                a:
243                    $ref: "#/$defs/a"
244            $ref: "#/$defs/a"
245        "##;
246        let root_schema = loader::load_from_str(schema).expect("Failed to load schema");
247        let context = crate::Context::with_root_schema(&root_schema, false);
248        let docs = saphyr::MarkedYaml::load_from_str("test").unwrap();
249        let value = docs.first().unwrap();
250        let result = root_schema.validate(&context, value);
251        assert!(result.is_ok());
252        assert!(context.has_errors());
253        let errors = context.errors.borrow();
254        assert_eq!(errors.len(), 1);
255        assert!(
256            errors[0].error.contains("Circular $ref detected"),
257            "Expected circular ref error, got: {}",
258            errors[0].error
259        );
260    }
261
262    #[test]
263    fn test_circular_reference_indirect() {
264        let schema = r##"
265            $defs:
266                a:
267                    $ref: "#/$defs/b"
268                b:
269                    $ref: "#/$defs/a"
270            $ref: "#/$defs/a"
271        "##;
272        let root_schema = loader::load_from_str(schema).expect("Failed to load schema");
273        let context = crate::Context::with_root_schema(&root_schema, false);
274        let docs = saphyr::MarkedYaml::load_from_str("test").unwrap();
275        let value = docs.first().unwrap();
276        let result = root_schema.validate(&context, value);
277        assert!(result.is_ok());
278        assert!(context.has_errors());
279        let errors = context.errors.borrow();
280        assert_eq!(errors.len(), 1);
281        assert!(
282            errors[0].error.contains("Circular $ref detected"),
283            "Expected circular ref error, got: {}",
284            errors[0].error
285        );
286    }
287
288    #[test]
289    fn test_non_circular_ref_still_works() {
290        let schema = r##"
291            $defs:
292                name:
293                    type: string
294            type: object
295            properties:
296                first_name:
297                    $ref: "#/$defs/name"
298                last_name:
299                    $ref: "#/$defs/name"
300        "##;
301        let root_schema = loader::load_from_str(schema).expect("Failed to load schema");
302        let context = crate::Context::with_root_schema(&root_schema, false);
303        let value = r#"
304            first_name: "Alice"
305            last_name: "Smith"
306        "#;
307        let docs = saphyr::MarkedYaml::load_from_str(value).unwrap();
308        let value = docs.first().unwrap();
309        let result = root_schema.validate(&context, value);
310        assert!(result.is_ok());
311        assert!(
312            !context.has_errors(),
313            "Expected no errors, got: {:?}",
314            context.errors.borrow()
315        );
316    }
317
318    #[test]
319    fn test_ref_uri_same_document() {
320        let r = RefUri::parse("#/$defs/name");
321        assert!(r.is_same_document());
322        assert_eq!(r.fragment(), Some("/$defs/name"));
323        assert_eq!(r.base_ref(), "");
324    }
325
326    #[test]
327    fn test_ref_uri_relative_with_fragment() {
328        let r = RefUri::parse("./common.yaml#/$defs/Id");
329        assert!(!r.is_same_document());
330        assert_eq!(r.fragment(), Some("/$defs/Id"));
331        assert_eq!(r.base_ref(), "./common.yaml");
332    }
333
334    #[test]
335    fn test_ref_uri_absolute() {
336        let r = RefUri::parse("https://example.com/schema.yaml#/$defs/User");
337        assert!(!r.is_same_document());
338        assert!(r.is_absolute());
339        assert_eq!(r.fragment(), Some("/$defs/User"));
340        assert_eq!(r.base_ref(), "https://example.com/schema.yaml");
341    }
342
343    #[test]
344    fn test_ref_uri_is_absolute() {
345        assert!(RefUri::parse("https://example.com/schema.yaml").is_absolute());
346        assert!(RefUri::parse("http://example.com/s.yaml#/$defs/X").is_absolute());
347        assert!(RefUri::parse("file:///tmp/schema.yaml").is_absolute());
348        assert!(!RefUri::parse("./common.yaml#/$defs/Id").is_absolute());
349        assert!(!RefUri::parse("#/$defs/name").is_absolute());
350        assert!(!RefUri::parse("common.yaml").is_absolute());
351    }
352
353    #[test]
354    fn test_ref_uri_resolve_relative() {
355        let r = RefUri::parse("./other.yaml#/$defs/foo");
356        let base = url::Url::parse("file:///dir/schema.yaml").unwrap();
357        let resolved = r.resolve_against(&base).unwrap();
358        assert!(resolved.as_str().contains("other.yaml"));
359        assert_eq!(resolved.fragment(), Some("/$defs/foo"));
360    }
361
362    #[test]
363    fn test_ref_accepts_external_ref() {
364        let schema = r##"
365            type: object
366            properties:
367                id:
368                    $ref: "./common.yaml#/$defs/Id"
369        "##;
370        let root_schema =
371            loader::load_from_str(schema).expect("Should load schema with external $ref");
372        let YamlSchema::Subschema(subschema) = &root_schema.schema else {
373            panic!("Expected Subschema");
374        };
375        let object_schema = subschema.object_schema.as_ref().unwrap();
376        let name_property = object_schema
377            .properties
378            .as_ref()
379            .unwrap()
380            .get("id")
381            .unwrap();
382        let YamlSchema::Subschema(prop_schema) = name_property else {
383            panic!("Expected Subschema for id property");
384        };
385        let ref_val = prop_schema.r#ref.as_ref().unwrap();
386        assert_eq!(ref_val.ref_name, "./common.yaml#/$defs/Id");
387    }
388}