Skip to content

Commit 155d6bc

Browse files
committed
fix: bundle internal sibling refs inside external schemas
When an external schema's sub-schema contains an internal `#/...` ref to a sibling definition, the previous bundler skipped it. The copied sub-schema then pointed into a namespace that no longer existed after bundling, leaving dangling refs in the output. The bundler now treats each internal ref inside a copied sub-schema as a synthetic external ref to the same source document and recurses, producing a single shared bundled definition per target. Local rewrites are applied to the sub-schema before it is registered, so its refs land on the bundled siblings.
1 parent ddf42e8 commit 155d6bc

1 file changed

Lines changed: 117 additions & 18 deletions

File tree

typify-impl/src/bundler.rs

Lines changed: 117 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -178,33 +178,61 @@ fn resolve_external_ref(
178178
}
179179
};
180180

181-
// Generate a unique name
181+
// Generate a unique name and reserve it. Insert into `rewrites`
182+
// before recursing so circular refs see the in-progress entry and
183+
// terminate instead of looping.
182184
let base_name = generate_external_name(&doc_uri, &fragment);
183185
let name = make_unique_name(&base_name, used_names);
184186
used_names.insert(name.clone());
185-
186-
// Register as a definition
187-
if let Ok(parsed) = serde_json::from_value::<schemars::schema::Schema>(sub_schema.clone()) {
188-
definitions.insert(name.clone(), parsed);
189-
}
190-
191187
rewrites.insert(ref_str.to_string(), format!("#/definitions/{}", name));
192188

193-
// Recursively resolve refs within the external schema
189+
// Walk every ref inside the copied sub-schema. For each:
190+
// - external ref (no leading `#`) — resolve as before.
191+
// - internal ref (`#/definitions/X`) — refers to a sibling
192+
// definition in the same external document. The bundler must
193+
// pull that sibling into the root's definitions and rewrite the
194+
// internal ref to the new bundled name, otherwise the copied
195+
// sub-schema points into a namespace that no longer exists.
196+
//
197+
// We treat each internal ref as a synthetic external ref
198+
// `{doc_uri}{#fragment}` and recurse — that produces a single,
199+
// shared bundled definition no matter how many places mention it.
194200
let nested_refs = collect_refs(&sub_schema);
201+
let mut local_rewrites: BTreeMap<String, String> = BTreeMap::new();
195202
for nested_ref in nested_refs {
196-
if !nested_ref.starts_with('#') {
197-
// Resolve relative to the same document's directory
198-
resolve_external_ref(
199-
&nested_ref,
200-
external_schemas,
201-
definitions,
202-
rewrites,
203-
used_names,
204-
visited,
205-
);
203+
let synthetic_target = if let Some(internal) = nested_ref.strip_prefix('#') {
204+
format!("{}#{}", doc_uri, internal)
205+
} else {
206+
nested_ref.clone()
207+
};
208+
209+
resolve_external_ref(
210+
&synthetic_target,
211+
external_schemas,
212+
definitions,
213+
rewrites,
214+
used_names,
215+
visited,
216+
);
217+
218+
if nested_ref.starts_with('#') {
219+
if let Some(new_target) = rewrites.get(&synthetic_target) {
220+
local_rewrites.insert(nested_ref, new_target.clone());
221+
}
206222
}
207223
}
224+
225+
// Apply the locally-collected rewrites to this sub-schema before
226+
// registering it, so internal refs in the copied definition resolve
227+
// to their bundled siblings.
228+
let mut bundled_schema = sub_schema;
229+
if !local_rewrites.is_empty() {
230+
rewrite_refs(&mut bundled_schema, &local_rewrites);
231+
}
232+
233+
if let Ok(parsed) = serde_json::from_value::<schemars::schema::Schema>(bundled_schema) {
234+
definitions.insert(name, parsed);
235+
}
208236
}
209237

210238
/// Recursively collect all `$ref` string values from a JSON value.
@@ -459,6 +487,77 @@ mod tests {
459487
}
460488
}
461489

490+
#[test]
491+
fn test_bundle_external_with_internal_sibling_ref() {
492+
// External `types.json` has `Outer` that references its sibling
493+
// `Inner` via an internal `#/definitions/Inner` ref. After
494+
// bundling, both must be present in the root definitions and
495+
// `Outer`'s ref must be rewritten to point at the bundled
496+
// `Inner` (not the original namespace, which no longer exists).
497+
let mut schema = root_schema_from_json(json!({
498+
"type": "object",
499+
"properties": {
500+
"outer": { "$ref": "types.json#/definitions/Outer" }
501+
}
502+
}));
503+
504+
let mut externals = BTreeMap::new();
505+
externals.insert(
506+
"types.json".to_string(),
507+
json!({
508+
"definitions": {
509+
"Outer": {
510+
"type": "object",
511+
"properties": {
512+
"inner": { "$ref": "#/definitions/Inner" }
513+
}
514+
},
515+
"Inner": { "type": "string" }
516+
}
517+
}),
518+
);
519+
520+
bundle_external_refs(&mut schema, &externals);
521+
522+
// The root `outer` ref points at the bundled Outer.
523+
let outer = &schema.schema.object.as_ref().unwrap().properties["outer"];
524+
let outer_ref = if let schemars::schema::Schema::Object(obj) = outer {
525+
obj.reference.clone().unwrap()
526+
} else {
527+
panic!("expected schema object")
528+
};
529+
let outer_name = outer_ref.strip_prefix("#/definitions/").unwrap();
530+
531+
// Both Outer and Inner are bundled.
532+
let outer_def = schema.definitions.get(outer_name).unwrap();
533+
let inner_ref = if let schemars::schema::Schema::Object(obj) = outer_def {
534+
obj.object
535+
.as_ref()
536+
.unwrap()
537+
.properties
538+
.get("inner")
539+
.and_then(|s| {
540+
if let schemars::schema::Schema::Object(o) = s {
541+
o.reference.clone()
542+
} else {
543+
None
544+
}
545+
})
546+
.unwrap()
547+
} else {
548+
panic!("expected schema object")
549+
};
550+
551+
// Outer's nested ref is rewritten to the bundled Inner — not
552+
// the original `#/definitions/Inner` that no longer exists.
553+
let inner_name = inner_ref.strip_prefix("#/definitions/").unwrap();
554+
assert!(
555+
schema.definitions.contains_key(inner_name),
556+
"rewritten inner ref {} not found in definitions",
557+
inner_ref
558+
);
559+
}
560+
462561
#[test]
463562
fn test_generate_name_from_pointer() {
464563
let used = BTreeSet::new();

0 commit comments

Comments
 (0)