1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
//! `$ref` resolution for registered GTS schemas: inlining `gts://` and local
//! `#/` references into a self-contained body. A `$ref` target is inlined at a
//! non-root position, so its root-only keys (`$id`/`$schema` and the `x-gts-*`
//! type-level modifiers) are stripped on the way in. `allOf` and the other
//! combinators are preserved verbatim — composition is left to the JSON Schema
//! validator, not flattened here.
//!
//! [`SchemaResolver`] depends only on the narrow [`SchemaProvider`] lookup, not
//! on `GtsStore` directly; the store implements `SchemaProvider` and exposes
//! `resolve_schema_refs` as a thin wrapper.
use serde_json::Value;
use crate::gts::GTS_ID_URI_PREFIX;
use crate::store::StoreError;
/// Read-only schema lookup the resolver needs from its host.
///
/// Implemented by `GtsStore`; abstracts the resolver away from the store's
/// internals so it can be exercised against any registry-like source.
pub(crate) trait SchemaProvider {
/// The registered schema document for the canonical type id `type_id`, or
/// `None` if no *schema* with that id is registered. Implementations must
/// return `None` for non-schema entities so a `$ref` to one stays
/// unresolved rather than silently inlining a non-schema body.
fn schema_content(&self, type_id: &str) -> Option<&Value>;
}
/// Inlines `$ref`s in a JSON Schema using a [`SchemaProvider`] for lookups.
pub(crate) struct SchemaResolver<'a> {
provider: &'a dyn SchemaProvider,
}
impl<'a> SchemaResolver<'a> {
pub(crate) fn new(provider: &'a dyn SchemaProvider) -> Self {
Self { provider }
}
/// Strict `$ref` resolution that returns an error if any supported local
/// JSON Pointer or external GTS `$ref` cannot be resolved, or a circular
/// `$ref` is detected.
///
/// Uses DFS-path cycle detection: a `$ref` target is held in the seen-set
/// only while its resolution is in progress on the current DFS stack and
/// removed once that subtree finishes. Re-entry into an in-progress target
/// is a true cycle. Multiple independent occurrences of the same `$ref`
/// (e.g. duplicate refs in `allOf`) are NOT flagged — redundant manual
/// aggregation across an `$id` chain is allowed.
///
/// # Errors
/// Returns [`StoreError::UnresolvedRefs`] if any supported local JSON
/// Pointer or external GTS `$ref` cannot be resolved, or
/// [`StoreError::CircularRef`] if a circular `$ref` is detected.
pub(crate) fn resolve(&self, schema: &Value) -> Result<Value, StoreError> {
let mut visited = std::collections::HashSet::new();
let mut cycle_found = false;
let mut unresolved_refs = Vec::new();
let resolved = self.resolve_inner(
schema,
schema,
&mut visited,
&mut cycle_found,
&mut unresolved_refs,
);
if cycle_found {
Err(StoreError::CircularRef)
} else if !unresolved_refs.is_empty() {
Err(StoreError::UnresolvedRefs(unresolved_refs))
} else {
Ok(resolved)
}
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
fn resolve_inner(
&self,
schema: &Value,
local_root: &Value,
visited: &mut std::collections::HashSet<String>,
cycle_found: &mut bool,
unresolved_refs: &mut Vec<String>,
) -> Value {
// Recursively resolve $ref references in the schema
match schema {
Value::Object(map) => {
if let Some(Value::String(ref_uri)) = map.get("$ref") {
// Handle internal JSON Schema references like #/$defs/GtsInstanceId
// These should be inlined to match schemars 0.8 behavior (is_referenceable=false)
match ref_uri.as_str() {
"#/$defs/GtsInstanceId" => {
return crate::GtsInstanceId::json_schema_value();
}
"#/$defs/GtsTypeId" | "#/$defs/GtsSchemaId" => {
return crate::GtsTypeId::json_schema_value();
}
s if s == "#" || s.starts_with("#/") => {
if let Some(pointer) = ref_uri.strip_prefix('#') {
let local_ref_key =
format!("local:{:p}:{ref_uri}", std::ptr::from_ref(local_root));
if visited.contains(&local_ref_key) {
*cycle_found = true;
return Value::Object(map.clone());
}
if let Some(target) = local_root.pointer(pointer) {
visited.insert(local_ref_key.clone());
let resolved = self.resolve_inner(
target,
local_root,
visited,
cycle_found,
unresolved_refs,
);
visited.remove(&local_ref_key);
return self.resolved_ref_with_siblings(
map,
resolved,
local_root,
visited,
cycle_found,
unresolved_refs,
);
}
unresolved_refs.push(ref_uri.clone());
}
// Unsupported or missing internal references are
// kept in the lenient output after being reported.
let mut new_map = serde_json::Map::new();
for (k, v) in map {
new_map.insert(
k.clone(),
self.resolve_inner(
v,
local_root,
visited,
cycle_found,
unresolved_refs,
),
);
}
return Value::Object(new_map);
}
_ => {} // Fall through to external ref handling
}
// Normalize the ref: strip gts:// prefix to get canonical GTS ID
let canonical_ref = ref_uri.strip_prefix(GTS_ID_URI_PREFIX).unwrap_or(ref_uri);
let (lookup_ref, pointer_fragment) =
if let Some((id, fragment)) = canonical_ref.split_once('#') {
let pointer = if fragment.is_empty() {
Some("")
} else if fragment.starts_with('/') {
Some(fragment)
} else {
None
};
(id, pointer)
} else {
(canonical_ref, None)
};
// Cycle detection: skip if we've already visited this ref
if visited.contains(canonical_ref) {
// Circular $ref detected. Keep this `$ref` in lenient
// output to avoid weakening the schema while preventing
// infinite recursion.
*cycle_found = true;
let mut new_map = serde_json::Map::new();
for (k, v) in map {
new_map.insert(
k.clone(),
if k == "$ref" {
v.clone()
} else {
self.resolve_inner(
v,
local_root,
visited,
cycle_found,
unresolved_refs,
)
},
);
}
return Value::Object(new_map);
}
// Try to resolve the reference using the canonical ID
if let Some(content) = self.provider.schema_content(lookup_ref) {
let target_content = match pointer_fragment {
Some("") => Some(content),
Some(pointer) => content.pointer(pointer),
None if canonical_ref.contains('#') => None,
None => Some(content),
};
if let Some(target_content) = target_content {
// Mark as visited before recursing
visited.insert(canonical_ref.to_owned());
// Recursively resolve refs in the referenced schema
let mut resolved = self.resolve_inner(
target_content,
content,
visited,
cycle_found,
unresolved_refs,
);
visited.remove(canonical_ref);
// The target is inlined at a non-root position (e.g. an
// `allOf` branch), so drop keys that are only meaningful at
// a type root: `$id`/`$schema` (URL/dialect resolution) and
// the type-level modifiers (they describe the referenced
// type, not the host; trait composition lives in
// `effective_traits`/`effective_traits_schema`). Everything
// else is preserved verbatim.
if let Value::Object(ref mut resolved_map) = resolved {
resolved_map.remove("$id");
resolved_map.remove("$schema");
resolved_map.remove(crate::schema_modifiers::X_GTS_FINAL);
resolved_map.remove(crate::schema_modifiers::X_GTS_ABSTRACT);
resolved_map.remove(crate::schema_traits::X_GTS_TRAITS);
resolved_map.remove(crate::schema_traits::X_GTS_TRAITS_SCHEMA);
}
return self.resolved_ref_with_siblings(
map,
resolved,
local_root,
visited,
cycle_found,
unresolved_refs,
);
}
}
if !ref_uri.starts_with('#') {
unresolved_refs.push(ref_uri.clone());
}
// If we can't resolve, keep the $ref in lenient output. Dropping
// it would silently weaken the schema, especially when only
// annotation siblings such as `description` remain.
let mut new_map = serde_json::Map::new();
for (k, v) in map {
new_map.insert(
k.clone(),
if k == "$ref" {
v.clone()
} else {
self.resolve_inner(
v,
local_root,
visited,
cycle_found,
unresolved_refs,
)
},
);
}
return Value::Object(new_map);
}
// `allOf` (and every other keyword) is handled by the generic
// recursion below: each branch is resolved in place, preserving
// the composition verbatim. We deliberately do NOT flatten
// branches into one object — that dropped non-property keywords
// and collapsed same-named properties to last-wins instead of
// intersection. Trait composition lives in `effective_traits`/
// `effective_traits_schema`, not in this resolved body.
// Recursively process all properties
let mut new_map = serde_json::Map::new();
for (k, v) in map {
new_map.insert(
k.clone(),
self.resolve_inner(v, local_root, visited, cycle_found, unresolved_refs),
);
}
Value::Object(new_map)
}
Value::Array(arr) => Value::Array(
arr.iter()
.map(|v| {
self.resolve_inner(v, local_root, visited, cycle_found, unresolved_refs)
})
.collect(),
),
_ => schema.clone(),
}
}
fn resolved_ref_with_siblings(
&self,
map: &serde_json::Map<String, Value>,
resolved: Value,
local_root: &Value,
visited: &mut std::collections::HashSet<String>,
cycle_found: &mut bool,
unresolved_refs: &mut Vec<String>,
) -> Value {
// If the original object has only $ref, return the resolved schema.
if map.len() == 1 {
return resolved;
}
// Otherwise combine the resolved schema with the siblings via `allOf`.
// A last-wins merge would let a sibling drop or loosen the target's
// constraints (e.g. `required`, `additionalProperties`).
match resolved {
Value::Object(resolved_map) => {
let mut siblings = serde_json::Map::new();
for (k, v) in map {
if k != "$ref" {
siblings.insert(
k.clone(),
self.resolve_inner(
v,
local_root,
visited,
cycle_found,
unresolved_refs,
),
);
}
}
if siblings.is_empty() {
return Value::Object(resolved_map);
}
let mut merged = serde_json::Map::new();
merged.insert(
"allOf".to_owned(),
Value::Array(vec![Value::Object(resolved_map), Value::Object(siblings)]),
);
Value::Object(merged)
}
// Non-object target (e.g. a boolean schema via a pointer fragment)
// with siblings: `$ref` wins per JSON Schema precedence.
other => other,
}
}
}
#[cfg(test)]
#[path = "schema_resolver_test.rs"]
mod schema_resolver_test;