wip(adapters): OAI-11 finisher checkpoint 2
This commit is contained in:
@@ -16,6 +16,16 @@ use serde_json::Value;
|
||||
/// exhausting the stack.
|
||||
pub(crate) const MAX_REF_RESOLUTION_DEPTH: usize = 64;
|
||||
|
||||
/// Maximum number of `Value` nodes materialized by one `resolve_refs_recursive`
|
||||
/// call (review 002 OAI-11). Memoization makes repeated `$ref` hops cheap, but
|
||||
/// the fully-inlined output of an acyclic shared-ref chain still grows
|
||||
/// exponentially in the chain length — a doubly-linked-list spec
|
||||
/// (`S_i` referenced by both `a` and `b` of `S_{i-1}`) inlines to ~2^N nodes.
|
||||
/// Neither the visited set nor the depth budget fires for that shape; this
|
||||
/// budget does, failing import with a clean [`AdapterError::SchemaParse`]
|
||||
/// instead of wedging with no error (or exhausting memory).
|
||||
pub(crate) const MAX_REF_EXPANSION_NODES: usize = 1_000_000;
|
||||
|
||||
/// The `paths`-level HTTP methods the adapter models. `trace` is
|
||||
/// deliberately absent (OAI-06): a path carrying only unsupported
|
||||
/// methods is skipped with a warning, not imported as a mis-behaving op.
|
||||
@@ -375,7 +385,7 @@ impl OpenAPISpec {
|
||||
/// 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
|
||||
/// as an exponential tree. All four 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
|
||||
@@ -383,6 +393,11 @@ impl OpenAPISpec {
|
||||
/// 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.
|
||||
///
|
||||
/// As the final bound (a memo alone cannot shrink the inlined output of
|
||||
/// an acyclic exponential shared-ref chain), a per-call node budget
|
||||
/// counts every materialized `Value` node; exceeding it fails import
|
||||
/// with a clean error naming the budget (OAI-11).
|
||||
pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result<Value, AdapterError> {
|
||||
self.resolve_refs_bounded(schema, &mut RefResolution::default(), 0)
|
||||
}
|
||||
@@ -401,6 +416,16 @@ impl OpenAPISpec {
|
||||
),
|
||||
});
|
||||
}
|
||||
state.nodes = state.nodes.saturating_add(1);
|
||||
if state.nodes > MAX_REF_EXPANSION_NODES {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"$ref expansion exceeded node budget of {MAX_REF_EXPANSION_NODES} \
|
||||
(acyclic shared-$ref chain expanding exponentially; the schema is \
|
||||
valid but its full inline expansion is too large to materialize)"
|
||||
),
|
||||
});
|
||||
}
|
||||
match schema {
|
||||
Value::Object(obj) => {
|
||||
if let Some(Value::String(reference)) = obj.get("$ref") {
|
||||
@@ -447,13 +472,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).
|
||||
/// Cycle-scoped state for [`OpenAPISpec::resolve_refs_recursive`]: the
|
||||
/// in-flight `$ref` chain (cycle detection), the memo of completed
|
||||
/// cycle-free expansions (total-work bounding, review 002 OAI-11), and the
|
||||
/// per-call node counter (the last-resort bound on inlined output size).
|
||||
#[derive(Default)]
|
||||
struct RefResolution {
|
||||
resolving: HashSet<String>,
|
||||
memo: HashMap<String, Value>,
|
||||
nodes: usize,
|
||||
}
|
||||
|
||||
fn parse_operation(
|
||||
@@ -845,8 +872,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thirty_level_shared_chain_imports_bounded() {
|
||||
let levels = 30usize;
|
||||
fn thirty_level_shared_chain_returns_bounded_with_clean_budget_error() {
|
||||
let levels = 32usize;
|
||||
let mut components = serde_json::Map::new();
|
||||
for i in 0..levels {
|
||||
let next_ref = if i + 1 < levels {
|
||||
@@ -869,24 +896,60 @@ mod tests {
|
||||
.expect("test schema present")
|
||||
.clone();
|
||||
let start = std::time::Instant::now();
|
||||
let resolved = spec
|
||||
.resolve_refs_recursive(&schema)
|
||||
.expect("acyclic shared chain resolves");
|
||||
let result = spec.resolve_refs_recursive(&schema);
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed.as_secs_f64() < 1.0,
|
||||
"30+ level shared chain must import in < 1 s, took {elapsed:?}"
|
||||
"30+ level shared chain must return in < 1 s, took {elapsed:?}"
|
||||
);
|
||||
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"
|
||||
);
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(
|
||||
message.contains("node budget"),
|
||||
"message was: {message}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected node-budget SchemaParse error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forty_level_single_chain_imports_fast_under_memoization() {
|
||||
let levels = 40usize;
|
||||
let mut components = serde_json::Map::new();
|
||||
for i in 0..levels {
|
||||
let next = if i + 1 < levels {
|
||||
json!({"$ref": format!("#/components/schemas/S{}", i + 1)})
|
||||
} else {
|
||||
json!({"type": "string"})
|
||||
};
|
||||
components.insert(format!("S{i}"), json!({"next": next}));
|
||||
}
|
||||
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 start = std::time::Instant::now();
|
||||
let resolved = spec
|
||||
.resolve_refs_recursive(&schema)
|
||||
.expect("acyclic chain resolves");
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed.as_secs_f64() < 1.0,
|
||||
"40-level chain must resolve in < 1 s, took {elapsed:?}"
|
||||
);
|
||||
let mut cursor = &resolved;
|
||||
for _ in 1..levels {
|
||||
cursor = &cursor["next"];
|
||||
}
|
||||
assert_eq!(cursor["type"], "string", "innermost level fully expanded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memoized_expansion_matches_pre_memoization_golden() {
|
||||
let spec = schema_test_spec(json!({
|
||||
|
||||
Reference in New Issue
Block a user