Skip to main content

openapi_deref/
error.rs

1use serde_json::Value;
2use thiserror::Error;
3
4/// Fatal error that invalidates the entire resolution.
5///
6/// When this is returned, the resolution was aborted and no [`ResolvedDoc`](crate::ResolvedDoc)
7/// is available. Currently the only fatal condition is exceeding the maximum
8/// recursion depth, which indicates the document is too deeply nested to
9/// guarantee a complete result.
10#[non_exhaustive]
11#[derive(Debug, Clone, PartialEq, Eq, Error)]
12pub enum ResolveError {
13    /// Maximum recursion depth exceeded — output cannot be guaranteed complete.
14    #[error("recursion depth limit ({max_depth}) exceeded")]
15    DepthExceeded {
16        /// The depth limit that was exceeded.
17        max_depth: u32,
18    },
19}
20
21/// Non-fatal error for a specific `$ref` pointer.
22///
23/// The resolver handled these gracefully by preserving the raw `$ref` object
24/// in the output, but the reference was **not** expanded.
25///
26/// Use [`ref_str()`](Self::ref_str) to extract the `$ref` value without
27/// pattern matching.
28///
29/// # Variants
30///
31/// | Variant | Cause | Raw `$ref` preserved? |
32/// |---|---|---|
33/// | [`External`](Self::External) | URI like `https://…` or `./file.json` | Yes |
34/// | [`TargetNotFound`](Self::TargetNotFound) | JSON Pointer resolves to nothing | Yes |
35/// | [`Cycle`](Self::Cycle) | Already visiting this ref (recursion loop) | Yes |
36/// | [`SiblingKeysIgnored`](Self::SiblingKeysIgnored) | Resolved target is not an object; siblings dropped | No (ref resolved) |
37#[non_exhaustive]
38#[derive(Debug, Clone, PartialEq, Eq, Error)]
39pub enum RefError {
40    /// External URI reference — not supported by this resolver.
41    ///
42    /// Produced when a `$ref` value does not start with `#`. Examples:
43    /// `https://example.com/schema.json`, `./common.yaml#/Foo`.
44    #[error("external reference not supported: {ref_str}")]
45    External {
46        /// The full `$ref` string (e.g. `"https://example.com/schema.json"`).
47        ref_str: String,
48    },
49
50    /// Internal reference target not found in the document.
51    ///
52    /// The `$ref` starts with `#` but the JSON Pointer does not resolve to
53    /// any value in the root document.
54    #[error("reference target not found: {ref_str}")]
55    TargetNotFound {
56        /// The full `$ref` string (e.g. `"#/components/schemas/Missing"`).
57        ref_str: String,
58    },
59
60    /// Circular reference detected — kept as raw `$ref` to break the cycle.
61    ///
62    /// The target exists but is already being resolved in the current call
63    /// stack. The raw `$ref` object is preserved to prevent infinite recursion.
64    #[error("circular reference detected: {ref_str}")]
65    Cycle {
66        /// The full `$ref` string (e.g. `"#/components/schemas/Node"`).
67        ref_str: String,
68    },
69
70    /// Sibling keys alongside `$ref` were dropped because the resolved
71    /// target is not a JSON object and merging is impossible.
72    ///
73    /// The `$ref` itself was successfully resolved, but any sibling keys
74    /// (e.g. `description`, `title`) present in the same object were lost
75    /// because they cannot be merged into a non-object value.
76    #[error("sibling keys ignored: resolved target is not an object for {ref_str}")]
77    SiblingKeysIgnored {
78        /// The full `$ref` string (e.g. `"#/definitions/status"`).
79        ref_str: String,
80    },
81}
82
83impl RefError {
84    /// Returns the `$ref` string that caused this error.
85    ///
86    /// This is a convenience accessor that avoids pattern matching when you
87    /// only need the ref string regardless of the error variant.
88    ///
89    /// # Example
90    ///
91    /// ```
92    /// use openapi_deref::RefError;
93    ///
94    /// let err = RefError::TargetNotFound { ref_str: "#/missing".into() };
95    /// assert_eq!(err.ref_str(), "#/missing");
96    /// ```
97    pub fn ref_str(&self) -> &str {
98        match self {
99            Self::External { ref_str }
100            | Self::TargetNotFound { ref_str }
101            | Self::Cycle { ref_str }
102            | Self::SiblingKeysIgnored { ref_str } => ref_str,
103        }
104    }
105}
106
107/// Unified error for [`resolve_strict`](crate::resolve_strict).
108///
109/// Covers both fatal resolution failures and non-fatal ref errors
110/// that are promoted to errors in strict mode.
111///
112/// # Inspecting failures
113///
114/// ```
115/// use serde_json::json;
116/// use openapi_deref::{resolve_strict, StrictResolveError};
117///
118/// let spec = json!({ "a": { "$ref": "#/nope" } });
119/// let err = resolve_strict(&spec).unwrap_err();
120///
121/// // Access partial value and ref errors without pattern matching
122/// if let Some(value) = err.partial_value() {
123///     assert_eq!(value["a"]["$ref"], "#/nope");
124/// }
125/// assert_eq!(err.ref_errors().len(), 1);
126/// ```
127#[non_exhaustive]
128#[derive(Debug, Clone, Error)]
129pub enum StrictResolveError {
130    /// Fatal resolution error (e.g. depth limit exceeded).
131    #[error(transparent)]
132    Fatal(#[from] ResolveError),
133
134    /// One or more refs could not be resolved.
135    #[error(transparent)]
136    Partial(#[from] PartialResolveError),
137}
138
139impl StrictResolveError {
140    /// Returns the partially resolved value if this was a partial failure.
141    ///
142    /// Returns `None` for fatal errors where no value was produced.
143    pub fn partial_value(&self) -> Option<&Value> {
144        match self {
145            Self::Partial(e) => Some(&e.value),
146            Self::Fatal(_) => None,
147        }
148    }
149
150    /// Returns ref-level errors if this was a partial failure.
151    ///
152    /// Returns an empty slice for fatal errors.
153    pub fn ref_errors(&self) -> &[RefError] {
154        match self {
155            Self::Partial(e) => &e.ref_errors,
156            Self::Fatal(_) => &[],
157        }
158    }
159}
160
161/// Error returned by [`ResolvedDoc::into_value`](crate::ResolvedDoc::into_value)
162/// when unresolved refs remain.
163///
164/// Provides access to both the partially-resolved document and the
165/// list of ref-level errors, enabling callers to inspect the best-effort
166/// result even on failure.
167///
168/// # Example
169///
170/// ```
171/// use serde_json::json;
172/// use openapi_deref::resolve;
173///
174/// let spec = json!({ "a": { "$ref": "#/missing" } });
175/// let err = resolve(&spec).unwrap().into_value().unwrap_err();
176///
177/// // The partial value still has resolved portions
178/// assert_eq!(err.value["a"]["$ref"], "#/missing");
179/// assert_eq!(err.ref_errors.len(), 1);
180/// eprintln!("{err}"); // "1 unresolved reference(s):\n  - reference target not found: #/missing"
181/// ```
182#[derive(Debug, Clone, PartialEq)]
183pub struct PartialResolveError {
184    /// The partially resolved document (resolvable refs were still expanded).
185    pub value: Value,
186    /// Non-fatal ref errors encountered during resolution.
187    pub ref_errors: Vec<RefError>,
188}
189
190impl std::fmt::Display for PartialResolveError {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        write!(f, "{} unresolved reference(s):", self.ref_errors.len())?;
193        for err in &self.ref_errors {
194            write!(f, "\n  - {err}")?;
195        }
196        Ok(())
197    }
198}
199
200impl std::error::Error for PartialResolveError {}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn partial_resolve_error_display() {
208        let err = PartialResolveError {
209            value: serde_json::json!({}),
210            ref_errors: vec![
211                RefError::TargetNotFound {
212                    ref_str: "#/a".to_string(),
213                },
214                RefError::External {
215                    ref_str: "https://x.com/b".to_string(),
216                },
217            ],
218        };
219        let display = err.to_string();
220        assert!(display.contains("2 unresolved reference(s)"));
221        assert!(display.contains("#/a"));
222        assert!(display.contains("https://x.com/b"));
223    }
224
225    #[test]
226    fn ref_error_display_variants() {
227        assert_eq!(
228            RefError::External {
229                ref_str: "https://x.com".to_string()
230            }
231            .to_string(),
232            "external reference not supported: https://x.com"
233        );
234        assert_eq!(
235            RefError::TargetNotFound {
236                ref_str: "#/a".to_string()
237            }
238            .to_string(),
239            "reference target not found: #/a"
240        );
241        assert_eq!(
242            RefError::Cycle {
243                ref_str: "#/b".to_string()
244            }
245            .to_string(),
246            "circular reference detected: #/b"
247        );
248        assert_eq!(
249            RefError::SiblingKeysIgnored {
250                ref_str: "#/c".to_string()
251            }
252            .to_string(),
253            "sibling keys ignored: resolved target is not an object for #/c"
254        );
255    }
256
257    #[test]
258    fn resolve_error_display() {
259        assert_eq!(
260            ResolveError::DepthExceeded { max_depth: 64 }.to_string(),
261            "recursion depth limit (64) exceeded"
262        );
263    }
264
265    #[test]
266    fn ref_error_ref_str_accessor() {
267        let external = RefError::External {
268            ref_str: "https://x.com/a".to_string(),
269        };
270        let not_found = RefError::TargetNotFound {
271            ref_str: "#/missing".to_string(),
272        };
273        let cycle = RefError::Cycle {
274            ref_str: "#/loop".to_string(),
275        };
276        let sibling = RefError::SiblingKeysIgnored {
277            ref_str: "#/val".to_string(),
278        };
279
280        assert_eq!(external.ref_str(), "https://x.com/a");
281        assert_eq!(not_found.ref_str(), "#/missing");
282        assert_eq!(cycle.ref_str(), "#/loop");
283        assert_eq!(sibling.ref_str(), "#/val");
284    }
285
286    #[test]
287    fn strict_error_fatal_has_no_partial_value() {
288        let err = StrictResolveError::Fatal(ResolveError::DepthExceeded { max_depth: 64 });
289
290        assert!(err.partial_value().is_none());
291        assert!(err.ref_errors().is_empty());
292    }
293}