wip(adapters): OAI-11 staging checkpoint (crashed session)
This commit is contained in:
+286
-19
@@ -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<Value, AdapterError> {
|
||||
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<String>,
|
||||
state: &mut RefResolution,
|
||||
depth: usize,
|
||||
) -> Result<Value, AdapterError> {
|
||||
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<String>,
|
||||
memo: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
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##"{
|
||||
|
||||
Reference in New Issue
Block a user