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}