Skip to main content

openapi_deref/
resolver.rs

1use std::collections::HashSet;
2
3use serde_json::Value;
4
5use crate::error::{RefError, ResolveError, StrictResolveError};
6use crate::resolved::ResolvedDoc;
7
8/// Maximum recursion depth to prevent stack overflow.
9const MAX_DEPTH: u32 = 64;
10
11// =============================================================================
12// Public API
13// =============================================================================
14
15/// Resolve all `$ref` pointers in a JSON document.
16///
17/// Returns `Ok(`[`ResolvedDoc`]`)` with the expanded document and any
18/// non-fatal ref errors, or `Err(`[`ResolveError`]`)` on fatal failure
19/// (e.g. depth limit exceeded).
20///
21/// # Behavior
22///
23/// - **Internal refs** (`#/…`) are expanded via JSON Pointer ([RFC 6901]).
24/// - **External refs** (e.g. `https://…`) are reported as [`RefError::External`]
25///   and preserved as raw `{"$ref": "…"}` objects.
26/// - **Cycles** are detected per resolution path and reported as
27///   [`RefError::Cycle`].
28/// - **`$ref` alongside sibling keys** (OpenAPI 3.1): sibling keys are
29///   merged into the resolved value. On key conflict, sibling keys
30///   override the resolved target. If the resolved target is not a JSON
31///   object, sibling keys cannot be merged and are reported as
32///   [`RefError::SiblingKeysIgnored`].
33/// - **Non-string `$ref`**: if the `$ref` value is not a JSON string
34///   (e.g. `{"$ref": 123}`), the object is treated as a regular object
35///   and no resolution is attempted.
36///
37/// [RFC 6901]: https://datatracker.ietf.org/doc/html/rfc6901
38///
39/// # Example
40///
41/// ```
42/// use serde_json::json;
43/// use openapi_deref::resolve;
44///
45/// let spec = json!({
46///     "components": { "schemas": {
47///         "User": { "type": "object", "properties": { "name": { "type": "string" } } }
48///     }},
49///     "schema": { "$ref": "#/components/schemas/User" }
50/// });
51///
52/// let doc = resolve(&spec).unwrap();
53/// assert!(doc.is_complete());
54/// assert_eq!(doc.value["schema"]["type"], "object");
55/// ```
56pub fn resolve(root: &Value) -> Result<ResolvedDoc, ResolveError> {
57    let mut ctx = Context {
58        root,
59        ref_errors: Vec::new(),
60        visiting: HashSet::new(),
61        seen_refs: HashSet::new(),
62    };
63    let value = resolve_value(&mut ctx, root, 0)?;
64    Ok(ResolvedDoc {
65        value,
66        ref_errors: ctx.ref_errors,
67    })
68}
69
70/// Resolve `$ref` pointers in a subtree, using a separate root document for
71/// reference lookup.
72///
73/// Useful when you want to resolve refs within a single schema against the
74/// full OpenAPI spec, without processing the entire spec.
75///
76/// # Example
77///
78/// ```
79/// use serde_json::json;
80/// use openapi_deref::resolve_with_root;
81///
82/// let root = json!({
83///     "components": {
84///         "schemas": {
85///             "Pet": { "type": "object", "properties": { "name": { "type": "string" } } }
86///         }
87///     }
88/// });
89///
90/// let schema = json!({ "$ref": "#/components/schemas/Pet" });
91/// let doc = resolve_with_root(&root, &schema).unwrap();
92/// assert_eq!(doc.value["type"], "object");
93/// ```
94pub fn resolve_with_root<'a>(
95    root: &'a Value,
96    target: &'a Value,
97) -> Result<ResolvedDoc, ResolveError> {
98    let mut ctx = Context {
99        root,
100        ref_errors: Vec::new(),
101        visiting: HashSet::new(),
102        seen_refs: HashSet::new(),
103    };
104    let value = resolve_value(&mut ctx, target, 0)?;
105    Ok(ResolvedDoc {
106        value,
107        ref_errors: ctx.ref_errors,
108    })
109}
110
111/// Strict resolution — returns `Ok(Value)` only if **all** refs are resolved
112/// and no fatal errors occur.
113///
114/// Equivalent to `resolve(root)?.into_value()` with a unified error type
115/// ([`StrictResolveError`]).
116///
117/// # Example
118///
119/// ```
120/// use serde_json::json;
121/// use openapi_deref::resolve_strict;
122///
123/// let spec = json!({
124///     "components": { "schemas": { "Id": { "type": "integer" } } },
125///     "field": { "$ref": "#/components/schemas/Id" }
126/// });
127///
128/// let value = resolve_strict(&spec).unwrap();
129/// assert_eq!(value["field"]["type"], "integer");
130/// ```
131pub fn resolve_strict(root: &Value) -> Result<Value, StrictResolveError> {
132    let doc = resolve(root)?;
133    Ok(doc.into_value()?)
134}
135
136// =============================================================================
137// Internal
138// =============================================================================
139
140struct Context<'a> {
141    root: &'a Value,
142    ref_errors: Vec<RefError>,
143    visiting: HashSet<String>,
144    seen_refs: HashSet<String>,
145}
146
147fn resolve_value(ctx: &mut Context<'_>, value: &Value, depth: u32) -> Result<Value, ResolveError> {
148    if depth > MAX_DEPTH {
149        return Err(ResolveError::DepthExceeded {
150            max_depth: MAX_DEPTH,
151        });
152    }
153    match value {
154        Value::Object(obj) => {
155            if let Some(ref_str) = obj.get("$ref").and_then(|v| v.as_str()) {
156                let resolved = resolve_ref(ctx, ref_str, depth)?;
157                // OpenAPI 3.1: merge sibling keys into resolved value.
158                // Sibling keys override the resolved target on conflict.
159                if obj.len() > 1 {
160                    if let Value::Object(mut resolved_obj) = resolved {
161                        for (k, v) in obj {
162                            if k != "$ref" {
163                                resolved_obj.insert(k.clone(), resolve_value(ctx, v, depth + 1)?);
164                            }
165                        }
166                        return Ok(Value::Object(resolved_obj));
167                    }
168                    // Resolved target is not a JSON object — sibling keys cannot be merged
169                    push_ref_error(
170                        ctx,
171                        ref_str,
172                        RefError::SiblingKeysIgnored {
173                            ref_str: ref_str.to_string(),
174                        },
175                    );
176                }
177                return Ok(resolved);
178            }
179            let new_obj: serde_json::Map<String, Value> = obj
180                .iter()
181                .map(|(k, v)| resolve_value(ctx, v, depth + 1).map(|rv| (k.clone(), rv)))
182                .collect::<Result<_, _>>()?;
183            Ok(Value::Object(new_obj))
184        }
185        Value::Array(arr) => {
186            let new_arr: Vec<Value> = arr
187                .iter()
188                .map(|v| resolve_value(ctx, v, depth + 1))
189                .collect::<Result<_, _>>()?;
190            Ok(Value::Array(new_arr))
191        }
192        _ => Ok(value.clone()),
193    }
194}
195
196fn resolve_ref(ctx: &mut Context<'_>, ref_str: &str, depth: u32) -> Result<Value, ResolveError> {
197    // Only handle internal references (#/...)
198    let pointer = match ref_str.strip_prefix('#') {
199        Some(p) => p,
200        None => {
201            push_ref_error(
202                ctx,
203                ref_str,
204                RefError::External {
205                    ref_str: ref_str.to_string(),
206                },
207            );
208            return Ok(serde_json::json!({ "$ref": ref_str }));
209        }
210    };
211
212    // Cycle detection
213    if ctx.visiting.contains(ref_str) {
214        push_ref_error(
215            ctx,
216            ref_str,
217            RefError::Cycle {
218                ref_str: ref_str.to_string(),
219            },
220        );
221        return Ok(serde_json::json!({ "$ref": ref_str }));
222    }
223
224    // Copy the root reference to avoid borrowing ctx during pointer lookup
225    let root = ctx.root;
226    let target = match root.pointer(pointer) {
227        Some(v) => v,
228        None => {
229            push_ref_error(
230                ctx,
231                ref_str,
232                RefError::TargetNotFound {
233                    ref_str: ref_str.to_string(),
234                },
235            );
236            return Ok(serde_json::json!({ "$ref": ref_str }));
237        }
238    };
239
240    // Mark as visiting, resolve recursively, then unmark
241    ctx.visiting.insert(ref_str.to_string());
242    let resolved = resolve_value(ctx, target, depth + 1)?;
243    ctx.visiting.remove(ref_str);
244
245    Ok(resolved)
246}
247
248fn push_ref_error(ctx: &mut Context<'_>, ref_str: &str, error: RefError) {
249    if ctx.seen_refs.insert(ref_str.to_string()) {
250        ctx.ref_errors.push(error);
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use serde_json::{json, Value};
257
258    use crate::{
259        resolve, resolve_strict, resolve_with_root, RefError, ResolveError, StrictResolveError,
260    };
261
262    use super::MAX_DEPTH;
263
264    // =========================================================================
265    // Normal: basic $ref resolution
266    // =========================================================================
267
268    #[test]
269    fn resolve_simple_schema_ref() {
270        let spec = json!({
271            "components": {
272                "schemas": {
273                    "User": { "type": "object", "properties": { "name": { "type": "string" } } }
274                }
275            },
276            "schema": { "$ref": "#/components/schemas/User" }
277        });
278        let doc = resolve(&spec).unwrap();
279
280        assert!(doc.is_complete());
281        assert_eq!(doc.value["schema"]["type"], "object");
282        assert_eq!(doc.value["schema"]["properties"]["name"]["type"], "string");
283    }
284
285    #[test]
286    fn resolve_parameter_ref() {
287        let spec = json!({
288            "components": {
289                "parameters": {
290                    "LimitParam": { "name": "limit", "in": "query", "schema": { "type": "integer" } }
291                }
292            },
293            "params": [
294                { "$ref": "#/components/parameters/LimitParam" }
295            ]
296        });
297        let doc = resolve(&spec).unwrap();
298
299        assert!(doc.is_complete());
300        assert_eq!(doc.value["params"][0]["name"], "limit");
301        assert_eq!(doc.value["params"][0]["in"], "query");
302    }
303
304    #[test]
305    fn resolve_response_ref() {
306        let spec = json!({
307            "components": {
308                "responses": {
309                    "NotFound": { "description": "Resource not found" }
310                }
311            },
312            "result": { "$ref": "#/components/responses/NotFound" }
313        });
314        let doc = resolve(&spec).unwrap();
315
316        assert!(doc.is_complete());
317        assert_eq!(doc.value["result"]["description"], "Resource not found");
318    }
319
320    // =========================================================================
321    // Normal: nested $ref
322    // =========================================================================
323
324    #[test]
325    fn resolve_nested_refs() {
326        let spec = json!({
327            "components": {
328                "schemas": {
329                    "Address": { "type": "object", "properties": { "city": { "type": "string" } } },
330                    "User": {
331                        "type": "object",
332                        "properties": {
333                            "address": { "$ref": "#/components/schemas/Address" }
334                        }
335                    }
336                }
337            },
338            "target": { "$ref": "#/components/schemas/User" }
339        });
340        let doc = resolve(&spec).unwrap();
341
342        assert!(doc.is_complete());
343        assert_eq!(
344            doc.value["target"]["properties"]["address"]["type"],
345            "object"
346        );
347        assert_eq!(
348            doc.value["target"]["properties"]["address"]["properties"]["city"]["type"],
349            "string"
350        );
351    }
352
353    #[test]
354    fn resolve_three_level_nesting() {
355        let spec = json!({
356            "components": {
357                "schemas": {
358                    "Country": { "type": "string" },
359                    "Address": {
360                        "type": "object",
361                        "properties": { "country": { "$ref": "#/components/schemas/Country" } }
362                    },
363                    "User": {
364                        "type": "object",
365                        "properties": { "address": { "$ref": "#/components/schemas/Address" } }
366                    }
367                }
368            },
369            "root": { "$ref": "#/components/schemas/User" }
370        });
371        let doc = resolve(&spec).unwrap();
372
373        assert!(doc.is_complete());
374        assert_eq!(
375            doc.value["root"]["properties"]["address"]["properties"]["country"]["type"],
376            "string"
377        );
378    }
379
380    // =========================================================================
381    // Normal: allOf / oneOf / anyOf with $ref
382    // =========================================================================
383
384    #[test]
385    fn resolve_all_of_with_refs() {
386        let spec = json!({
387            "components": {
388                "schemas": {
389                    "Base": { "type": "object", "properties": { "id": { "type": "integer" } } },
390                    "Extra": { "type": "object", "properties": { "tag": { "type": "string" } } }
391                }
392            },
393            "combined": {
394                "allOf": [
395                    { "$ref": "#/components/schemas/Base" },
396                    { "$ref": "#/components/schemas/Extra" }
397                ]
398            }
399        });
400        let doc = resolve(&spec).unwrap();
401
402        assert!(doc.is_complete());
403        let all_of = doc.value["combined"]["allOf"].as_array().unwrap();
404        assert_eq!(all_of[0]["properties"]["id"]["type"], "integer");
405        assert_eq!(all_of[1]["properties"]["tag"]["type"], "string");
406    }
407
408    #[test]
409    fn resolve_one_of_with_refs() {
410        let spec = json!({
411            "components": {
412                "schemas": {
413                    "Cat": { "type": "object", "properties": { "purrs": { "type": "boolean" } } },
414                    "Dog": { "type": "object", "properties": { "barks": { "type": "boolean" } } }
415                }
416            },
417            "pet": {
418                "oneOf": [
419                    { "$ref": "#/components/schemas/Cat" },
420                    { "$ref": "#/components/schemas/Dog" }
421                ]
422            }
423        });
424        let doc = resolve(&spec).unwrap();
425
426        assert!(doc.is_complete());
427        let one_of = doc.value["pet"]["oneOf"].as_array().unwrap();
428        assert_eq!(one_of[0]["properties"]["purrs"]["type"], "boolean");
429        assert_eq!(one_of[1]["properties"]["barks"]["type"], "boolean");
430    }
431
432    // =========================================================================
433    // Normal: resolve_with_root
434    // =========================================================================
435
436    #[test]
437    fn resolve_with_root_basic() {
438        let root = json!({
439            "components": {
440                "schemas": {
441                    "Item": { "type": "string" }
442                }
443            }
444        });
445        let target = json!({ "$ref": "#/components/schemas/Item" });
446        let doc = resolve_with_root(&root, &target).unwrap();
447
448        assert!(doc.is_complete());
449        assert_eq!(doc.value["type"], "string");
450    }
451
452    // =========================================================================
453    // Normal: no $ref (pass-through)
454    // =========================================================================
455
456    #[test]
457    fn no_refs_returns_identical() {
458        let spec = json!({
459            "type": "object",
460            "properties": { "x": { "type": "integer" } }
461        });
462        let doc = resolve(&spec).unwrap();
463
464        assert!(doc.is_complete());
465        assert_eq!(doc.value, spec);
466    }
467
468    // =========================================================================
469    // Normal: array of $ref
470    // =========================================================================
471
472    #[test]
473    fn resolve_refs_in_array() {
474        let spec = json!({
475            "components": {
476                "schemas": {
477                    "A": { "type": "string" },
478                    "B": { "type": "integer" }
479                }
480            },
481            "items": [
482                { "$ref": "#/components/schemas/A" },
483                { "$ref": "#/components/schemas/B" }
484            ]
485        });
486        let doc = resolve(&spec).unwrap();
487
488        assert!(doc.is_complete());
489        assert_eq!(doc.value["items"][0]["type"], "string");
490        assert_eq!(doc.value["items"][1]["type"], "integer");
491    }
492
493    // =========================================================================
494    // RefError: circular reference
495    // =========================================================================
496
497    #[test]
498    fn detect_direct_cycle() {
499        let spec = json!({
500            "components": {
501                "schemas": {
502                    "Node": {
503                        "type": "object",
504                        "properties": {
505                            "child": { "$ref": "#/components/schemas/Node" }
506                        }
507                    }
508                }
509            },
510            "root": { "$ref": "#/components/schemas/Node" }
511        });
512        let doc = resolve(&spec).unwrap();
513
514        assert!(!doc.is_complete());
515        assert!(doc.ref_errors.contains(&RefError::Cycle {
516            ref_str: "#/components/schemas/Node".to_string(),
517        }));
518        assert_eq!(
519            doc.value["root"]["properties"]["child"]["$ref"],
520            "#/components/schemas/Node"
521        );
522    }
523
524    #[test]
525    fn detect_indirect_cycle() {
526        let spec = json!({
527            "components": {
528                "schemas": {
529                    "A": { "type": "object", "properties": { "b": { "$ref": "#/components/schemas/B" } } },
530                    "B": { "type": "object", "properties": { "a": { "$ref": "#/components/schemas/A" } } }
531                }
532            },
533            "start": { "$ref": "#/components/schemas/A" }
534        });
535        let doc = resolve(&spec).unwrap();
536
537        assert!(!doc.is_complete());
538        let has_cycle = doc
539            .ref_errors
540            .iter()
541            .any(|e| matches!(e, RefError::Cycle { .. }));
542        assert!(has_cycle);
543    }
544
545    // =========================================================================
546    // RefError: unresolved reference
547    // =========================================================================
548
549    #[test]
550    fn report_missing_ref() {
551        let spec = json!({
552            "schema": { "$ref": "#/components/schemas/DoesNotExist" }
553        });
554        let doc = resolve(&spec).unwrap();
555
556        assert_eq!(doc.ref_errors.len(), 1);
557        assert!(doc.ref_errors.contains(&RefError::TargetNotFound {
558            ref_str: "#/components/schemas/DoesNotExist".to_string(),
559        }));
560        assert_eq!(
561            doc.value["schema"]["$ref"],
562            "#/components/schemas/DoesNotExist"
563        );
564    }
565
566    #[test]
567    fn report_external_ref() {
568        let spec = json!({
569            "schema": { "$ref": "https://example.com/schemas/External.json" }
570        });
571        let doc = resolve(&spec).unwrap();
572
573        assert_eq!(doc.ref_errors.len(), 1);
574        assert!(doc.ref_errors.contains(&RefError::External {
575            ref_str: "https://example.com/schemas/External.json".to_string(),
576        }));
577    }
578
579    #[test]
580    fn multiple_unresolved_refs_deduplicated() {
581        let spec = json!({
582            "a": { "$ref": "#/components/schemas/Missing" },
583            "b": { "$ref": "#/components/schemas/Missing" },
584            "c": { "$ref": "#/components/schemas/AlsoMissing" }
585        });
586        let doc = resolve(&spec).unwrap();
587
588        assert_eq!(doc.ref_errors.len(), 2);
589        assert!(doc.ref_errors.contains(&RefError::TargetNotFound {
590            ref_str: "#/components/schemas/Missing".to_string(),
591        }));
592        assert!(doc.ref_errors.contains(&RefError::TargetNotFound {
593            ref_str: "#/components/schemas/AlsoMissing".to_string(),
594        }));
595    }
596
597    // =========================================================================
598    // Edge: empty / null / scalar inputs
599    // =========================================================================
600
601    #[test]
602    fn resolve_null_value() {
603        let doc = resolve(&Value::Null).unwrap();
604        assert_eq!(doc.value, Value::Null);
605        assert!(doc.is_complete());
606    }
607
608    #[test]
609    fn resolve_scalar_value() {
610        let doc = resolve(&json!(42)).unwrap();
611        assert_eq!(doc.value, json!(42));
612    }
613
614    #[test]
615    fn resolve_empty_object() {
616        let doc = resolve(&json!({})).unwrap();
617        assert_eq!(doc.value, json!({}));
618    }
619
620    #[test]
621    fn resolve_empty_array() {
622        let doc = resolve(&json!([])).unwrap();
623        assert_eq!(doc.value, json!([]));
624    }
625
626    // =========================================================================
627    // OpenAPI 3.1: $ref alongside sibling keys
628    // =========================================================================
629
630    #[test]
631    fn ref_with_sibling_keys_merges() {
632        let spec = json!({
633            "components": {
634                "schemas": {
635                    "Item": { "type": "string" }
636                }
637            },
638            "target": {
639                "$ref": "#/components/schemas/Item",
640                "description": "Overridden description"
641            }
642        });
643        let doc = resolve(&spec).unwrap();
644
645        assert!(doc.is_complete());
646        assert_eq!(doc.value["target"]["type"], "string");
647        assert_eq!(doc.value["target"]["description"], "Overridden description");
648    }
649
650    #[test]
651    fn ref_sibling_overrides_resolved_key() {
652        let spec = json!({
653            "components": {
654                "schemas": {
655                    "Item": { "type": "string", "description": "Original" }
656                }
657            },
658            "target": {
659                "$ref": "#/components/schemas/Item",
660                "description": "Overridden"
661            }
662        });
663        let doc = resolve(&spec).unwrap();
664
665        assert!(doc.is_complete());
666        assert_eq!(doc.value["target"]["type"], "string");
667        assert_eq!(doc.value["target"]["description"], "Overridden");
668    }
669
670    #[test]
671    fn ref_sibling_with_nested_ref() {
672        let spec = json!({
673            "components": {
674                "schemas": {
675                    "Name": { "type": "string" },
676                    "User": { "type": "object", "properties": { "name": { "type": "string" } } }
677                }
678            },
679            "target": {
680                "$ref": "#/components/schemas/User",
681                "title": "Extended User",
682                "extra": { "$ref": "#/components/schemas/Name" }
683            }
684        });
685        let doc = resolve(&spec).unwrap();
686
687        assert!(doc.is_complete());
688        assert_eq!(doc.value["target"]["type"], "object");
689        assert_eq!(doc.value["target"]["title"], "Extended User");
690        assert_eq!(doc.value["target"]["extra"]["type"], "string");
691    }
692
693    // =========================================================================
694    // RefError: sibling keys ignored (non-object target)
695    // =========================================================================
696
697    #[test]
698    fn ref_sibling_keys_ignored_when_target_is_string() {
699        let spec = json!({
700            "definitions": { "status": "active" },
701            "target": {
702                "$ref": "#/definitions/status",
703                "description": "This will be dropped"
704            }
705        });
706        let doc = resolve(&spec).unwrap();
707
708        assert!(!doc.is_complete());
709        assert!(doc.ref_errors.contains(&RefError::SiblingKeysIgnored {
710            ref_str: "#/definitions/status".to_string(),
711        }));
712        assert_eq!(doc.value["target"], "active");
713    }
714
715    #[test]
716    fn ref_sibling_keys_ignored_when_target_is_array() {
717        let spec = json!({
718            "definitions": { "tags": ["a", "b"] },
719            "target": {
720                "$ref": "#/definitions/tags",
721                "description": "dropped"
722            }
723        });
724        let doc = resolve(&spec).unwrap();
725
726        assert!(!doc.is_complete());
727        assert!(doc.ref_errors.contains(&RefError::SiblingKeysIgnored {
728            ref_str: "#/definitions/tags".to_string(),
729        }));
730        assert_eq!(doc.value["target"], json!(["a", "b"]));
731    }
732
733    #[test]
734    fn ref_sibling_keys_ignored_not_reported_for_object_target() {
735        let spec = json!({
736            "definitions": { "item": { "type": "string" } },
737            "target": {
738                "$ref": "#/definitions/item",
739                "description": "merged"
740            }
741        });
742        let doc = resolve(&spec).unwrap();
743
744        assert!(doc.is_complete());
745        assert_eq!(doc.value["target"]["type"], "string");
746        assert_eq!(doc.value["target"]["description"], "merged");
747    }
748
749    #[test]
750    fn ref_sibling_keys_ignored_strict_mode_fails() {
751        let spec = json!({
752            "definitions": { "val": 42 },
753            "target": {
754                "$ref": "#/definitions/val",
755                "title": "dropped"
756            }
757        });
758        let err = resolve_strict(&spec).unwrap_err();
759
760        assert!(matches!(err, StrictResolveError::Partial(_)));
761        assert_eq!(err.ref_errors().len(), 1);
762        assert_eq!(err.ref_errors()[0].ref_str(), "#/definitions/val");
763    }
764
765    // =========================================================================
766    // Edge: JSON Pointer with special characters (RFC 6901 escaping)
767    // =========================================================================
768
769    #[test]
770    fn resolve_ref_with_slash_in_key() {
771        let spec = json!({
772            "definitions": {
773                "application/json": { "type": "string" }
774            },
775            "target": { "$ref": "#/definitions/application~1json" }
776        });
777        let doc = resolve(&spec).unwrap();
778
779        assert!(doc.is_complete());
780        assert_eq!(doc.value["target"]["type"], "string");
781    }
782
783    #[test]
784    fn resolve_ref_with_tilde_in_key() {
785        let spec = json!({
786            "definitions": {
787                "my~schema": { "type": "integer" }
788            },
789            "target": { "$ref": "#/definitions/my~0schema" }
790        });
791        let doc = resolve(&spec).unwrap();
792
793        assert!(doc.is_complete());
794        assert_eq!(doc.value["target"]["type"], "integer");
795    }
796
797    // =========================================================================
798    // Edge: $ref value is not a string
799    // =========================================================================
800
801    #[test]
802    fn ref_non_string_value_treated_as_regular_object() {
803        let spec = json!({
804            "schema": { "$ref": 123 }
805        });
806        let doc = resolve(&spec).unwrap();
807
808        assert!(doc.is_complete());
809        assert_eq!(doc.value["schema"]["$ref"], 123);
810    }
811
812    // =========================================================================
813    // Edge: $ref "#" points to root (self-reference)
814    // =========================================================================
815
816    #[test]
817    fn root_self_reference_detected_as_cycle() {
818        let spec = json!({
819            "type": "object",
820            "self": { "$ref": "#" }
821        });
822        let doc = resolve(&spec).unwrap();
823
824        assert!(!doc.is_complete());
825        assert!(doc.ref_errors.contains(&RefError::Cycle {
826            ref_str: "#".to_string(),
827        }));
828    }
829
830    // =========================================================================
831    // Fatal: depth exceeded
832    // =========================================================================
833
834    #[test]
835    fn depth_exceeded_is_fatal() {
836        let mut value = json!({ "leaf": true });
837        for _ in 0..=MAX_DEPTH {
838            value = json!({ "nested": value });
839        }
840        let result = resolve(&value);
841        assert!(matches!(
842            result,
843            Err(ResolveError::DepthExceeded {
844                max_depth: MAX_DEPTH
845            })
846        ));
847    }
848
849    // =========================================================================
850    // resolve_with_root: unresolved ref
851    // =========================================================================
852
853    #[test]
854    fn resolve_with_root_missing_ref() {
855        let root = json!({});
856        let target = json!({ "$ref": "#/components/schemas/Missing" });
857        let doc = resolve_with_root(&root, &target).unwrap();
858
859        assert!(doc.ref_errors.contains(&RefError::TargetNotFound {
860            ref_str: "#/components/schemas/Missing".to_string(),
861        }));
862    }
863
864    // =========================================================================
865    // resolve_strict
866    // =========================================================================
867
868    #[test]
869    fn resolve_strict_ok() {
870        let spec = json!({
871            "components": { "schemas": { "Id": { "type": "integer" } } },
872            "field": { "$ref": "#/components/schemas/Id" }
873        });
874        let value = resolve_strict(&spec).unwrap();
875        assert_eq!(value["field"]["type"], "integer");
876    }
877
878    #[test]
879    fn resolve_strict_fails_on_unresolved_ref() {
880        let spec = json!({
881            "schema": { "$ref": "#/missing" }
882        });
883        let err = resolve_strict(&spec).unwrap_err();
884        assert!(matches!(err, StrictResolveError::Partial(_)));
885    }
886
887    #[test]
888    fn resolve_strict_fails_on_fatal_error() {
889        let mut value = json!({ "leaf": true });
890        for _ in 0..=MAX_DEPTH {
891            value = json!({ "nested": value });
892        }
893        let err = resolve_strict(&value).unwrap_err();
894        assert!(matches!(
895            err,
896            StrictResolveError::Fatal(ResolveError::DepthExceeded { .. })
897        ));
898    }
899
900    // =========================================================================
901    // StrictResolveError accessors
902    // =========================================================================
903
904    #[test]
905    fn strict_error_partial_value_accessor() {
906        let spec = json!({
907            "components": { "schemas": { "Id": { "type": "integer" } } },
908            "ok": { "$ref": "#/components/schemas/Id" },
909            "broken": { "$ref": "#/missing" }
910        });
911        let err = resolve_strict(&spec).unwrap_err();
912
913        let value = err.partial_value().unwrap();
914        assert_eq!(value["ok"]["type"], "integer");
915        assert_eq!(value["broken"]["$ref"], "#/missing");
916
917        assert_eq!(err.ref_errors().len(), 1);
918        assert_eq!(err.ref_errors()[0].ref_str(), "#/missing");
919    }
920
921    // =========================================================================
922    // Realistic: OpenAPI-like structure
923    // =========================================================================
924
925    #[test]
926    fn resolve_realistic_openapi_spec() {
927        let spec = json!({
928            "openapi": "3.0.3",
929            "components": {
930                "schemas": {
931                    "Error": {
932                        "type": "object",
933                        "properties": {
934                            "code": { "type": "integer" },
935                            "message": { "type": "string" }
936                        },
937                        "required": ["code", "message"]
938                    },
939                    "User": {
940                        "type": "object",
941                        "properties": {
942                            "id": { "type": "string" },
943                            "email": { "type": "string", "format": "email" }
944                        }
945                    }
946                },
947                "parameters": {
948                    "UserId": {
949                        "name": "user_id",
950                        "in": "path",
951                        "required": true,
952                        "schema": { "type": "string" }
953                    }
954                }
955            },
956            "paths": {
957                "/users/{user_id}": {
958                    "get": {
959                        "parameters": [
960                            { "$ref": "#/components/parameters/UserId" }
961                        ],
962                        "responses": {
963                            "200": {
964                                "content": {
965                                    "application/json": {
966                                        "schema": { "$ref": "#/components/schemas/User" }
967                                    }
968                                }
969                            },
970                            "404": {
971                                "content": {
972                                    "application/json": {
973                                        "schema": { "$ref": "#/components/schemas/Error" }
974                                    }
975                                }
976                            }
977                        }
978                    }
979                }
980            }
981        });
982        let doc = resolve(&spec).unwrap();
983
984        assert!(doc.is_complete(), "ref_errors: {:?}", doc.ref_errors);
985
986        let param = &doc.value["paths"]["/users/{user_id}"]["get"]["parameters"][0];
987        assert_eq!(param["name"], "user_id");
988        assert_eq!(param["in"], "path");
989
990        let user_schema = &doc.value["paths"]["/users/{user_id}"]["get"]["responses"]["200"]
991            ["content"]["application/json"]["schema"];
992        assert_eq!(user_schema["type"], "object");
993        assert_eq!(user_schema["properties"]["email"]["format"], "email");
994
995        let error_schema = &doc.value["paths"]["/users/{user_id}"]["get"]["responses"]["404"]
996            ["content"]["application/json"]["schema"];
997        assert_eq!(error_schema["properties"]["code"]["type"], "integer");
998    }
999}