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#[derive(Clone, Debug, PartialEq)]
13pub struct RefUri {
14 raw: String,
16 base_ref: String,
18 fragment: Option<String>,
20}
21
22impl RefUri {
23 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 pub fn is_same_document(&self) -> bool {
39 self.raw.starts_with('#')
40 }
41
42 pub fn is_absolute(&self) -> bool {
44 Url::parse(&self.base_ref).is_ok()
45 }
46
47 pub fn fragment(&self) -> Option<&str> {
49 self.fragment.as_deref()
50 }
51
52 pub fn base_ref(&self) -> &str {
54 &self.base_ref
55 }
56
57 pub fn as_str(&self) -> &str {
59 &self.raw
60 }
61
62 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#[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!(
174 subschema.r#type.is_or_contains("object"),
175 "Expected object type"
176 );
177
178 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 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 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 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 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}