From f52b32a68fd31ac709aa1631e45e6282c8b323f8 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 12:43:40 +0000 Subject: [PATCH] wip(adapters): OAI-11 staging checkpoint (crashed session) --- src/adapters/openapi_spec.rs | 305 ++++++++++++++++++++++++++++++++--- 1 file changed, 286 insertions(+), 19 deletions(-) diff --git a/src/adapters/openapi_spec.rs b/src/adapters/openapi_spec.rs index cc1df5b..e5f6dda 100644 --- a/src/adapters/openapi_spec.rs +++ b/src/adapters/openapi_spec.rs @@ -367,22 +367,30 @@ impl OpenAPISpec { /// Resolve `$ref` pointers within `schema` against the raw document, /// returning a fully expanded copy. /// - /// Recursion is bounded: a visited set keyed on the JSON-pointer path - /// rejects a `$ref` that is already being expanded on the current - /// branch (a self/circular reference — trees, linked lists, cursor - /// pagination), and a depth budget rejects pathologically deep - /// nesting. Both surface as [`AdapterError::SchemaParse`] at import - /// instead of recursing to stack exhaustion. Fully-expanded inlining - /// of recursive schemas is deliberately not supported; specs that - /// rely on it fail loudly here (review 001 OAI-01). + /// Recursion is bounded in both stack and total work: a visited set + /// keyed on the JSON-pointer path rejects a `$ref` that is already + /// being expanded on the current branch (a self/circular reference — + /// trees, linked lists, cursor pagination), a depth budget rejects + /// pathologically deep nesting, and a per-document memo of completed + /// cycle-free `$ref` expansions ensures each distinct ref target is + /// expanded once and reused by clone on every later hop (review 002 + /// OAI-11) — an acyclic shared-ref chain grows linearly instead of + /// as an exponential tree. All three surface as + /// [`AdapterError::SchemaParse`] at import instead of recursing to + /// stack exhaustion or wedging without an error. Fully-expanded + /// inlining of recursive schemas is deliberately not supported; specs + /// that rely on it fail loudly here (review 001 OAI-01, review 002 + /// OAI-11). Memo entries are written only after a successful + /// expansion, so a partial expansion under a cyclic branch is never + /// cached and cycle detection semantics are unchanged. pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result { - self.resolve_refs_bounded(schema, &mut HashSet::new(), 0) + self.resolve_refs_bounded(schema, &mut RefResolution::default(), 0) } fn resolve_refs_bounded( &self, schema: &Value, - resolving: &mut HashSet, + state: &mut RefResolution, depth: usize, ) -> Result { if depth > MAX_REF_RESOLUTION_DEPTH { @@ -396,7 +404,10 @@ impl OpenAPISpec { match schema { Value::Object(obj) => { if let Some(Value::String(reference)) = obj.get("$ref") { - if !resolving.insert(reference.clone()) { + if let Some(resolved) = state.memo.get(reference) { + return Ok(resolved.clone()); + } + if !state.resolving.insert(reference.clone()) { return Err(AdapterError::SchemaParse { message: format!( "circular $ref detected at depth {depth}: {reference}" @@ -404,9 +415,11 @@ impl OpenAPISpec { }); } let resolved = self.resolve_ref(reference)?; - let out = self.resolve_refs_bounded(&resolved, resolving, depth + 1); - resolving.remove(reference); - return out; + let out = self.resolve_refs_bounded(&resolved, state, depth + 1); + state.resolving.remove(reference); + let value = out?; + state.memo.insert(reference.clone(), value.clone()); + return Ok(value); } if depth + 1 > MAX_REF_RESOLUTION_DEPTH { return Err(AdapterError::SchemaParse { @@ -418,17 +431,14 @@ impl OpenAPISpec { } let mut out = serde_json::Map::new(); for (k, v) in obj { - out.insert( - k.clone(), - self.resolve_refs_bounded(v, resolving, depth + 1)?, - ); + out.insert(k.clone(), self.resolve_refs_bounded(v, state, depth + 1)?); } Ok(Value::Object(out)) } Value::Array(arr) => { let mut out = Vec::with_capacity(arr.len()); for v in arr { - out.push(self.resolve_refs_bounded(v, resolving, depth + 1)?); + out.push(self.resolve_refs_bounded(v, state, depth + 1)?); } Ok(Value::Array(out)) } @@ -437,6 +447,15 @@ impl OpenAPISpec { } } +/// Branch-scoped state for [`OpenAPISpec::resolve_refs_recursive`]: the +/// in-flight `$ref` chain (cycle detection) and the memo of completed +/// cycle-free expansions (total-work bounding, review 002 OAI-11). +#[derive(Default)] +struct RefResolution { + resolving: HashSet, + memo: HashMap, +} + fn parse_operation( raw: &Value, spec: &OpenAPISpec, @@ -825,6 +844,254 @@ mod tests { assert_eq!(resolved["properties"]["a"]["type"], "string"); } + #[test] + fn thirty_level_shared_chain_imports_bounded() { + let levels = 30usize; + let mut components = serde_json::Map::new(); + for i in 0..levels { + let next_ref = if i + 1 < levels { + json!({"$ref": format!("#/components/schemas/S{}", i + 1)}) + } else { + json!({"type": "string"}) + }; + components.insert( + format!("S{i}"), + json!({"a": next_ref.clone(), "b": next_ref}), + ); + } + let spec = schema_test_spec(Value::Object(components)); + let schema = spec + .components + .as_ref() + .expect("test spec has schemas") + .schemas + .get("S0") + .expect("test schema present") + .clone(); + let visited = std::cell::Cell::new(0u64); + let start = std::time::Instant::now(); + let resolved = spec + .resolve_refs_recursive(&schema) + .expect("acyclic shared chain resolves"); + let elapsed = start.elapsed(); + visited.set(visited.get()); + assert!( + elapsed.as_secs_f64() < 1.0, + "30+ level shared chain must import in < 1 s, took {elapsed:?}" + ); + assert!(visited.get() == 0); + let a = &resolved["a"]; + assert_eq!(a["a"]["a"]["type"], "string"); + for side in ["a", "b"] { + assert_eq!( + resolved[side]["a"]["type"], "string", + "both branches fully expanded" + ); + } + } + + #[test] + fn memoized_expansion_matches_pre_memoization_golden() { + let spec = schema_test_spec(json!({ + "Id": {"type": "string", "maxLength": 4}, + "Stamp": {"type": "object", "required": ["at"]}, + "Chain": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/Id"}, + "next": { + "type": "object", + "properties": { + "id": {"$ref": "#/components/schemas/Id"}, + "stamp": {"$ref": "#/components/schemas/Stamp"}, + "again": {"$ref": "#/components/schemas/Id"} + } + }, + "stamp": {"$ref": "#/components/schemas/Stamp"} + } + } + })); + let schema = spec + .components + .as_ref() + .expect("test spec has schemas") + .schemas + .get("Chain") + .expect("test schema present") + .clone(); + let resolved = spec + .resolve_refs_recursive(&schema) + .expect("expansion succeeds"); + let expected = json!({ + "type": "object", + "properties": { + "id": {"type": "string", "maxLength": 4}, + "next": { + "type": "object", + "properties": { + "id": {"type": "string", "maxLength": 4}, + "stamp": {"type": "object", "required": ["at"]}, + "again": {"type": "string", "maxLength": 4} + } + }, + "stamp": {"type": "object", "required": ["at"]} + } + }); + assert_eq!(resolved, expected); + } + + #[test] + fn cycle_through_shared_node_still_errors() { + let spec = schema_test_spec(json!({ + "Shared": {"properties": { + "back": {"$ref": "#/components/schemas/Loop"} + }}, + "Loop": {"properties": { + "shared": {"$ref": "#/components/schemas/Shared"} + }} + })); + let schema = spec + .components + .as_ref() + .expect("test spec has schemas") + .schemas + .get("Loop") + .expect("test schema present") + .clone(); + let result = spec.resolve_refs_recursive(&schema); + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!(message.contains("circular $ref"), "message was: {message}"); + } + other => panic!("expected circular-$ref SchemaParse error, got {other:?}"), + } + } + + #[test] + fn cycle_via_array_items_errors() { + let spec = schema_test_spec(json!({ + "Node": {"type": "array", "items": {"$ref": "#/components/schemas/Node"}} + })); + let schema = spec + .components + .as_ref() + .expect("test spec has schemas") + .schemas + .get("Node") + .expect("test schema present") + .clone(); + let result = spec.resolve_refs_recursive(&schema); + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!(message.contains("circular $ref"), "message was: {message}"); + assert!(message.contains("Node"), "message was: {message}"); + } + other => panic!("expected circular-$ref SchemaParse error, got {other:?}"), + } + } + + #[test] + fn cycle_via_all_of_errors() { + let spec = schema_test_spec(json!({ + "Node": {"allOf": [{"$ref": "#/components/schemas/Node"}]} + })); + let schema = spec + .components + .as_ref() + .expect("test spec has schemas") + .schemas + .get("Node") + .expect("test schema present") + .clone(); + let result = spec.resolve_refs_recursive(&schema); + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!(message.contains("circular $ref"), "message was: {message}"); + assert!(message.contains("Node"), "message was: {message}"); + } + other => panic!("expected circular-$ref SchemaParse error, got {other:?}"), + } + } + + #[test] + fn cycle_via_additional_properties_errors() { + let spec = schema_test_spec(json!({ + "Node": {"type": "object", "additionalProperties": { + "$ref": "#/components/schemas/Node" + }} + })); + let schema = spec + .components + .as_ref() + .expect("test spec has schemas") + .schemas + .get("Node") + .expect("test schema present") + .clone(); + let result = spec.resolve_refs_recursive(&schema); + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!(message.contains("circular $ref"), "message was: {message}"); + assert!(message.contains("Node"), "message was: {message}"); + } + other => panic!("expected circular-$ref SchemaParse error, got {other:?}"), + } + } + + #[test] + fn cycle_via_ref_sibling_errors() { + let spec = schema_test_spec(json!({ + "Node": { + "$ref": "#/components/schemas/Node", + "minLength": 3 + } + })); + let schema = spec + .components + .as_ref() + .expect("test spec has schemas") + .schemas + .get("Node") + .expect("test schema present") + .clone(); + let result = spec.resolve_refs_recursive(&schema); + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!(message.contains("circular $ref"), "message was: {message}"); + assert!(message.contains("Node"), "message was: {message}"); + } + other => panic!("expected circular-$ref SchemaParse error, got {other:?}"), + } + } + + #[test] + fn memo_hit_matches_fresh_expansion_for_acyclic_shared_refs() { + let spec = schema_test_spec(json!({ + "Id": {"type": "string", "maxLength": 4}, + "Wrapper": { + "type": "object", + "properties": { + "a": {"$ref": "#/components/schemas/Id"}, + "b": {"$ref": "#/components/schemas/Id"} + } + } + })); + let schema = spec + .components + .as_ref() + .expect("test spec has schemas") + .schemas + .get("Wrapper") + .expect("test schema present") + .clone(); + let resolved = spec + .resolve_refs_recursive(&schema) + .expect("no false cycle"); + assert_eq!(resolved["properties"]["a"], resolved["properties"]["b"]); + assert_eq!(resolved["properties"]["a"]["type"], "string"); + assert_eq!(resolved["properties"]["a"]["maxLength"], 4); + } + #[test] fn import_of_self_referential_spec_returns_error_not_abort() { let doc = r##"{