Skip to main content

json_schema_rs/json_schema/
ref_resolver.rs

1//! Fragment-only `$ref` resolution against a root schema.
2//!
3//! Supported forms in this crate:
4//! - `#` (or empty string) → root schema
5//! - `#/$defs/<name>` → lookup in root `$defs`
6//! - `#/definitions/<name>` → lookup in root `definitions`
7//!
8//! Remote references, `$id`-relative resolution, anchors, and full JSON Pointer traversal are out of scope.
9
10use crate::json_schema::JsonSchema;
11use std::collections::HashSet;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum RefResolutionError {
15    /// `$ref` is not a fragment-only reference supported by this crate.
16    UnsupportedRef { ref_str: String },
17    /// Fragment exists but does not match one of the supported paths.
18    UnsupportedFragment { ref_str: String },
19    /// `$defs` container is missing on the root schema.
20    DefsMissing { ref_str: String },
21    /// `definitions` container is missing on the root schema.
22    DefinitionsMissing { ref_str: String },
23    /// The requested key was not found under `$defs`.
24    DefNotFound { ref_str: String, name: String },
25    /// The requested key was not found under `definitions`.
26    DefinitionNotFound { ref_str: String, name: String },
27    /// The `$ref` chain contains a cycle.
28    RefCycle { ref_str: String },
29    /// JSON Pointer escape sequence is invalid.
30    InvalidPointerEscape { ref_str: String },
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub enum ParsedRef {
35    Root,
36    Defs(String),
37    Definitions(String),
38}
39
40fn decode_json_pointer_segment(seg: &str, ref_str: &str) -> Result<String, RefResolutionError> {
41    if !seg.contains('~') {
42        return Ok(seg.to_string());
43    }
44
45    // JSON Pointer: "~1" => "/", "~0" => "~"
46    let mut out: String = String::with_capacity(seg.len());
47    let mut chars = seg.chars();
48    while let Some(c) = chars.next() {
49        if c != '~' {
50            out.push(c);
51            continue;
52        }
53        match chars.next() {
54            Some('0') => out.push('~'),
55            Some('1') => out.push('/'),
56            _ => {
57                return Err(RefResolutionError::InvalidPointerEscape {
58                    ref_str: ref_str.to_string(),
59                });
60            }
61        }
62    }
63    Ok(out)
64}
65
66/// Parses a fragment-only `$ref` string into a [`ParsedRef`].
67///
68/// # Errors
69///
70/// Returns [`RefResolutionError`] for non-fragment refs, unsupported fragment paths,
71/// invalid JSON Pointer escapes, or malformed segments.
72pub fn parse_ref(ref_str: &str) -> Result<ParsedRef, RefResolutionError> {
73    if ref_str.is_empty() || ref_str == "#" {
74        return Ok(ParsedRef::Root);
75    }
76    if !ref_str.starts_with('#') {
77        return Err(RefResolutionError::UnsupportedRef {
78            ref_str: ref_str.to_string(),
79        });
80    }
81    let frag = &ref_str[1..];
82    if frag.is_empty() {
83        return Ok(ParsedRef::Root);
84    }
85    if !frag.starts_with('/') {
86        return Err(RefResolutionError::UnsupportedFragment {
87            ref_str: ref_str.to_string(),
88        });
89    }
90
91    // Only allow "#/$defs/<name>" and "#/definitions/<name>"
92    let mut parts = frag[1..].split('/');
93    let container = parts.next().unwrap_or_default();
94    let raw_name = parts.next().unwrap_or_default();
95    // Must have exactly two segments.
96    if container.is_empty() || raw_name.is_empty() || parts.next().is_some() {
97        return Err(RefResolutionError::UnsupportedFragment {
98            ref_str: ref_str.to_string(),
99        });
100    }
101
102    let name = decode_json_pointer_segment(raw_name, ref_str)?;
103    match container {
104        "$defs" => Ok(ParsedRef::Defs(name)),
105        "definitions" => Ok(ParsedRef::Definitions(name)),
106        _ => Err(RefResolutionError::UnsupportedFragment {
107            ref_str: ref_str.to_string(),
108        }),
109    }
110}
111
112/// Resolves a fragment-only `$ref` against the root schema (single step).
113///
114/// # Errors
115///
116/// Returns [`RefResolutionError`] when the ref is unsupported, the container is missing,
117/// or the definition name is not found.
118pub fn resolve_ref<'a>(
119    root: &'a JsonSchema,
120    ref_str: &str,
121) -> Result<&'a JsonSchema, RefResolutionError> {
122    match parse_ref(ref_str)? {
123        ParsedRef::Root => Ok(root),
124        ParsedRef::Defs(name) => {
125            let defs = root
126                .defs
127                .as_ref()
128                .ok_or_else(|| RefResolutionError::DefsMissing {
129                    ref_str: ref_str.to_string(),
130                })?;
131            let target = defs
132                .get(&name)
133                .ok_or_else(|| RefResolutionError::DefNotFound {
134                    ref_str: ref_str.to_string(),
135                    name,
136                })?;
137            Ok(target)
138        }
139        ParsedRef::Definitions(name) => {
140            let definitions = root.definitions.as_ref().ok_or_else(|| {
141                RefResolutionError::DefinitionsMissing {
142                    ref_str: ref_str.to_string(),
143                }
144            })?;
145            let target =
146                definitions
147                    .get(&name)
148                    .ok_or_else(|| RefResolutionError::DefinitionNotFound {
149                        ref_str: ref_str.to_string(),
150                        name,
151                    })?;
152            Ok(target)
153        }
154    }
155}
156
157/// Resolves `$ref` on a schema node transitively until the effective schema has no `$ref`.
158///
159/// Cycle detection is performed on the `$ref` strings encountered.
160///
161/// # Errors
162///
163/// Returns [`RefResolutionError`] when any step fails (unsupported ref, missing def, or cycle).
164pub fn resolve_schema_ref_transitive<'a>(
165    root: &'a JsonSchema,
166    schema: &'a JsonSchema,
167) -> Result<&'a JsonSchema, RefResolutionError> {
168    let mut current: &'a JsonSchema = schema;
169    let mut visited: HashSet<&'a str> = HashSet::new();
170
171    while let Some(ref_str) = current.ref_.as_deref() {
172        if visited.contains(ref_str) {
173            return Err(RefResolutionError::RefCycle {
174                ref_str: ref_str.to_string(),
175            });
176        }
177        visited.insert(ref_str);
178        current = resolve_ref(root, ref_str)?;
179    }
180
181    Ok(current)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::{
187        ParsedRef, RefResolutionError, parse_ref, resolve_ref, resolve_schema_ref_transitive,
188    };
189    use crate::json_schema::JsonSchema;
190
191    #[test]
192    fn parse_ref_defs() {
193        let actual = parse_ref("#/$defs/Foo").unwrap();
194        let expected = ParsedRef::Defs("Foo".to_string());
195        assert_eq!(expected, actual);
196    }
197
198    #[test]
199    fn parse_ref_definitions() {
200        let actual = parse_ref("#/definitions/Foo").unwrap();
201        let expected = ParsedRef::Definitions("Foo".to_string());
202        assert_eq!(expected, actual);
203    }
204
205    #[test]
206    fn parse_ref_root_hash() {
207        let actual = parse_ref("#").unwrap();
208        let expected = ParsedRef::Root;
209        assert_eq!(expected, actual);
210    }
211
212    #[test]
213    fn parse_ref_root_empty_string() {
214        let actual = parse_ref("").unwrap();
215        let expected = ParsedRef::Root;
216        assert_eq!(expected, actual);
217    }
218
219    #[test]
220    fn parse_ref_unsupported_non_fragment() {
221        let actual = parse_ref("http://example.com/schema.json").unwrap_err();
222        let expected = RefResolutionError::UnsupportedRef {
223            ref_str: "http://example.com/schema.json".to_string(),
224        };
225        assert_eq!(expected, actual);
226    }
227
228    #[test]
229    fn parse_ref_unsupported_extra_segments() {
230        let actual = parse_ref("#/$defs/Foo/bar").unwrap_err();
231        let expected = RefResolutionError::UnsupportedFragment {
232            ref_str: "#/$defs/Foo/bar".to_string(),
233        };
234        assert_eq!(expected, actual);
235    }
236
237    #[test]
238    fn parse_ref_invalid_pointer_escape() {
239        let actual = parse_ref("#/$defs/Foo~").unwrap_err();
240        let expected = RefResolutionError::InvalidPointerEscape {
241            ref_str: "#/$defs/Foo~".to_string(),
242        };
243        assert_eq!(expected, actual);
244    }
245
246    #[test]
247    fn resolve_ref_defs_success() {
248        let root: JsonSchema = serde_json::from_str(
249            r#"{
250  "$defs": {
251    "Foo": { "type": "string", "title": "FooType" }
252  }
253}"#,
254        )
255        .unwrap();
256        let actual: &JsonSchema = resolve_ref(&root, "#/$defs/Foo").expect("resolve Foo");
257        let expected: JsonSchema = JsonSchema {
258            type_: Some("string".to_string()),
259            title: Some("FooType".to_string()),
260            ..Default::default()
261        };
262        assert_eq!(expected, *actual);
263    }
264
265    #[test]
266    fn resolve_ref_definitions_success() {
267        let root: JsonSchema = serde_json::from_str(
268            r#"{
269  "definitions": {
270    "Bar": { "type": "integer", "title": "BarType" }
271  }
272}"#,
273        )
274        .unwrap();
275        let actual: &JsonSchema = resolve_ref(&root, "#/definitions/Bar").expect("resolve Bar");
276        let expected: JsonSchema = JsonSchema {
277            type_: Some("integer".to_string()),
278            title: Some("BarType".to_string()),
279            ..Default::default()
280        };
281        assert_eq!(expected, *actual);
282    }
283
284    #[test]
285    fn resolve_ref_root_returns_root() {
286        let root: JsonSchema =
287            serde_json::from_str(r#"{"type":"object","properties":{"x":{"type":"string"}}}"#)
288                .unwrap();
289        let actual: &JsonSchema = resolve_ref(&root, "#").expect("resolve root");
290        let expected: &JsonSchema = &root;
291        assert_eq!(expected, actual);
292    }
293
294    #[test]
295    fn resolve_ref_decodes_pointer_segment() {
296        let root: JsonSchema = serde_json::from_str(
297            r#"{
298  "$defs": {
299    "Foo/bar": { "type": "string" }
300  }
301}"#,
302        )
303        .unwrap();
304        let actual: &JsonSchema = resolve_ref(&root, "#/$defs/Foo~1bar").expect("resolve Foo/bar");
305        let expected: JsonSchema = JsonSchema {
306            type_: Some("string".to_string()),
307            ..Default::default()
308        };
309        assert_eq!(expected, *actual);
310    }
311
312    #[test]
313    fn resolve_ref_missing_defs_errors() {
314        let root: JsonSchema =
315            serde_json::from_str(r#"{"type":"object","properties":{}}"#).unwrap();
316        let actual = resolve_ref(&root, "#/$defs/Foo").unwrap_err();
317        let expected = RefResolutionError::DefsMissing {
318            ref_str: "#/$defs/Foo".to_string(),
319        };
320        assert_eq!(expected, actual);
321    }
322
323    #[test]
324    fn resolve_ref_not_found_errors() {
325        let root: JsonSchema = serde_json::from_str(
326            r#"{"$defs":{"Bar":{"type":"string"}},"type":"object","properties":{}}"#,
327        )
328        .unwrap();
329        let actual = resolve_ref(&root, "#/$defs/Foo").unwrap_err();
330        let expected = RefResolutionError::DefNotFound {
331            ref_str: "#/$defs/Foo".to_string(),
332            name: "Foo".to_string(),
333        };
334        assert_eq!(expected, actual);
335    }
336
337    #[test]
338    fn resolve_schema_ref_transitive_cycle_errors() {
339        let root: JsonSchema = serde_json::from_str(
340            r##"{
341  "$defs": {
342    "A": { "$ref": "#/$defs/B" },
343    "B": { "$ref": "#/$defs/A" }
344  },
345  "$ref": "#/$defs/A"
346}"##,
347        )
348        .unwrap();
349        let actual = resolve_schema_ref_transitive(&root, &root).unwrap_err();
350        let expected = RefResolutionError::RefCycle {
351            ref_str: "#/$defs/A".to_string(),
352        };
353        assert_eq!(expected, actual);
354    }
355}