Skip to content

Commit ef40ee8

Browse files
committed
Replace inline_refs with rewrite_refs to fix CI hang on large specs
inline_refs fully expanded every $ref, producing hundreds of MB of JSON from a 7 MB Stripe spec — causing the CI runner to hang indefinitely. rewrite_refs is O(nodes): it only rewrites local $ref strings from '#/...' to 'urn:ace:openapi-root#/...' so the jsonschema compiler resolves them against the registered root document. No expansion, no exponential blowup, no cycle risk.
1 parent 6cdf887 commit ef40ee8

1 file changed

Lines changed: 39 additions & 78 deletions

File tree

crates/engine/src/schema.rs

Lines changed: 39 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use model::SchemaRef;
22
use serde_json::Value;
3-
use std::collections::HashSet;
43
use std::path::Path;
54

65
/// Synthetic URI we register the original OpenAPI document under so the
@@ -63,7 +62,7 @@ pub fn resolve(
6362
} => {
6463
let doc = load_json_or_yaml(base_dir, openapi)?;
6564
let schema = extract_component(&doc, component)?;
66-
let mut resolved = inline_refs(schema, &doc, &mut HashSet::new());
65+
let mut resolved = rewrite_refs(schema);
6766
if *strict {
6867
apply_strict(&mut resolved);
6968
}
@@ -78,60 +77,36 @@ fn extract_component(doc: &Value, name: &str) -> Result<Value, SchemaError> {
7877
.ok_or_else(|| SchemaError::ComponentNotFound(name.to_string()))
7978
}
8079

81-
/// Walk `schema`, replacing `{"$ref": "#/components/schemas/X"}` with the
82-
/// resolved subschema from `root`. Tracks visited refs to break cycles; on a
83-
/// cycle the `$ref` object is preserved but rewritten to point at the
84-
/// synthetic root URI (`urn:ace:openapi-root#/...`) so the `jsonschema`
85-
/// crate can resolve it against the registered root document at compile
86-
/// time. Without that rewrite, the bare `#/components/schemas/X` would
87-
/// resolve relative to the extracted standalone schema (which has no
88-
/// `components` section) and fail to compile.
89-
fn inline_refs(schema: Value, root: &Value, visiting: &mut HashSet<String>) -> Value {
80+
/// Walk `schema` and rewrite every local `$ref` (`#/...`) to the synthetic
81+
/// root URI (`urn:ace:openapi-root#/...`). This is O(nodes) with no
82+
/// recursion explosion — we never expand refs inline. The jsonschema
83+
/// compiler resolves them against the root document registered via
84+
/// `with_document`. Replacing the old `inline_refs` expansion avoids
85+
/// blowing up on large specs (e.g. Stripe's 7 MB OpenAPI file) where full
86+
/// inlining produces hundreds of MB of JSON.
87+
fn rewrite_refs(schema: Value) -> Value {
9088
match schema {
9189
Value::Object(mut map) => {
9290
if let Some(Value::String(ref_str)) = map.get("$ref") {
93-
let ref_str = ref_str.clone();
94-
if let Some(ptr) = local_ref_to_pointer(&ref_str) {
95-
if visiting.contains(&ref_str) {
96-
// Cycle: rewrite to the synthetic root URI so the
97-
// jsonschema compiler can resolve it via the
98-
// registered root document.
99-
map.insert(
100-
"$ref".to_string(),
101-
Value::String(format!("{SYNTHETIC_OPENAPI_ROOT_URI}{ref_str}")),
102-
);
103-
return Value::Object(map);
104-
}
105-
if let Some(target) = root.pointer(&ptr) {
106-
visiting.insert(ref_str.clone());
107-
let inlined = inline_refs(target.clone(), root, visiting);
108-
visiting.remove(&ref_str);
109-
return inlined;
110-
}
91+
if ref_str.starts_with('#') {
92+
let rewritten =
93+
format!("{SYNTHETIC_OPENAPI_ROOT_URI}{ref_str}");
94+
map.insert("$ref".to_string(), Value::String(rewritten));
11195
}
112-
// Non-local ref or pointer not found — leave as-is.
11396
return Value::Object(map);
11497
}
11598
for val in map.values_mut() {
116-
*val = inline_refs(val.take(), root, visiting);
99+
*val = rewrite_refs(val.take());
117100
}
118101
Value::Object(map)
119102
}
120-
Value::Array(arr) => Value::Array(
121-
arr.into_iter()
122-
.map(|v| inline_refs(v, root, visiting))
123-
.collect(),
124-
),
103+
Value::Array(arr) => {
104+
Value::Array(arr.into_iter().map(rewrite_refs).collect())
105+
}
125106
other => other,
126107
}
127108
}
128109

129-
/// Convert a local JSON Reference like `#/components/schemas/Foo` to a
130-
/// JSON Pointer `/components/schemas/Foo`.
131-
fn local_ref_to_pointer(ref_str: &str) -> Option<String> {
132-
ref_str.strip_prefix('#').map(|s| s.to_string())
133-
}
134-
135110
/// Walk `schema` and inject `"additionalProperties": false` on every
136111
/// `type: object` node (or any object node without an explicit type) that
137112
/// doesn't already have `additionalProperties` set. Recurses into
@@ -355,54 +330,40 @@ mod tests {
355330
}
356331

357332
#[test]
358-
fn inline_refs_simple() {
359-
let root = json!({
360-
"components": {
361-
"schemas": {
362-
"Address": { "type": "object", "properties": { "city": { "type": "string" } } }
363-
}
364-
}
365-
});
333+
fn rewrite_refs_rewrites_local_refs() {
366334
let schema = json!({
367335
"type": "object",
368336
"properties": {
369337
"addr": { "$ref": "#/components/schemas/Address" }
370338
}
371339
});
372-
let result = inline_refs(schema, &root, &mut HashSet::new());
373-
let addr = &result["properties"]["addr"];
374-
assert_eq!(addr["type"], "object");
375-
assert!(addr.get("$ref").is_none());
340+
let result = rewrite_refs(schema);
341+
assert_eq!(
342+
result["properties"]["addr"]["$ref"],
343+
format!("{SYNTHETIC_OPENAPI_ROOT_URI}#/components/schemas/Address")
344+
);
376345
}
377346

378347
#[test]
379-
fn inline_refs_cycle() {
380-
// LinkedList node references itself.
381-
let root = json!({
382-
"components": {
383-
"schemas": {
384-
"Node": {
385-
"type": "object",
386-
"properties": {
387-
"next": { "$ref": "#/components/schemas/Node" }
388-
}
389-
}
390-
}
348+
fn rewrite_refs_leaves_external_refs_alone() {
349+
let schema = json!({ "$ref": "https://example.com/schema.json" });
350+
let result = rewrite_refs(schema);
351+
assert_eq!(result["$ref"], "https://example.com/schema.json");
352+
}
353+
354+
#[test]
355+
fn rewrite_refs_cycle_safe() {
356+
// Deep nesting should not stack-overflow — rewrite_refs never recurses
357+
// into the value of a $ref, it just rewrites the string.
358+
let schema = json!({
359+
"type": "object",
360+
"properties": {
361+
"next": { "$ref": "#/components/schemas/Node" }
391362
}
392363
});
393-
let schema = root["components"]["schemas"]["Node"].clone();
394-
// Should not stack-overflow. The first-level ref is expanded once;
395-
// the nested forward ref is preserved as a $ref string so the
396-
// jsonschema compiler can resolve it against the root doc.
397-
let result = inline_refs(schema, &root, &mut HashSet::new());
398-
// First expansion: next becomes the Node object.
399-
assert_eq!(result["properties"]["next"]["type"], "object");
400-
// Second level: the nested next ref is preserved (cycle guard fired)
401-
// but rewritten to the synthetic root URI so the jsonschema compiler
402-
// can resolve it via the registered root document.
403-
let nested_next = &result["properties"]["next"]["properties"]["next"];
364+
let result = rewrite_refs(schema);
404365
assert_eq!(
405-
nested_next["$ref"],
366+
result["properties"]["next"]["$ref"],
406367
format!("{SYNTHETIC_OPENAPI_ROOT_URI}#/components/schemas/Node")
407368
);
408369
}

0 commit comments

Comments
 (0)