fix(adapters): warn on $ref sibling keys, document 3.0-only reading (OAI-10)

$ref siblings are ignored under OpenAPI 3.0 semantics but would apply
under 3.1 — a 3.1-authored constraint beside a $ref previously
vanished silently, overstating /schema. warn_ref_siblings now fires at
each $ref consumption point (operation parameters, requestBody,
path-item parameters) naming the location and dropped keys, and the
module doc records the version stance: no openapi 3.1 gate, 3.0
reading with the warn as the visibility mechanism.
This commit is contained in:
2026-08-31 00:50:17 +00:00
parent 688b7e91b2
commit 3f0b59b7d5
+90 -15
View File
@@ -32,6 +32,17 @@
//! - **Bare `yes`/`no`/`on`/`off` and tags** — identical behavior on
//! both paths by YAML 1.2 core schema (strings, `!!str 200` →
//! `"200"`); unknown tags fail loudly on both.
//!
//! # Version stance (review 002 OAI-10)
//!
//! Documents are interpreted under **OpenAPI 3.0 semantics**; there is
//! no `openapi: 3.1` version gate. The one divergence that matters here
//! is `$ref` siblings: 3.0 ignores them, 3.1 applies them alongside the
//! resolved target. A `$ref` carrying sibling keys therefore imports
//! with the 3.0 reading and a `tracing::warn` naming the dropped keys —
//! the advertise/enforce drift a 3.1-authored constraint would
//! otherwise hide is visible at import instead of surfacing as a
//! silent `/schema` overstatement.
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -493,8 +504,9 @@ impl OpenAPISpec {
return Err(AdapterError::SchemaParse {
message: format!(
"unresolvable $ref or missing `name`/`in` in parameter of \
{method} {path}, or an unresolvable/content-less `requestBody` \
on the operation (review 001 OAI-04, review 002 OAI-15)"
{method} {path}, or an unresolvable/content-less \
`requestBody` on the operation (review 001 OAI-04, \
review 002 OAI-15)"
),
});
}
@@ -735,6 +747,31 @@ fn count_nodes(value: &Value) -> usize {
}
}
/// Warns once per offending `$ref` object about sibling keys left
/// beside the `$ref` (review 002 OAI-10): under OpenAPI 3.0 the
/// siblings are ignored, but 3.1 applies them alongside the reference —
/// so a document authored against 3.1 semantics would advertise
/// constraints (`minLength: 3`) through `/schema` that the adapter's
/// resolved schema (used at call time) does not carry. There is no
/// `openapi: 3.1` version gate; the import proceeds with the 3.0
/// reading while naming the dropped keys.
fn warn_ref_siblings(context: &str, holder: &Value) {
if let Some(obj) = holder.as_object() {
let siblings: Vec<&String> = obj.keys().filter(|k| *k != "$ref").collect();
if !siblings.is_empty() {
let names: Vec<String> = siblings.iter().map(|s| s.as_str().to_string()).collect();
tracing::warn!(
location = %context,
siblings = %names.join(", "),
"$ref carries sibling keys; OpenAPI 3.0 semantics apply — the \
siblings are ignored (not merged into the resolved target as \
3.1 would do), so constraints authored beside the $ref are \
not enforced at call time (review 002 OAI-10)"
);
}
}
}
/// Parses one operation (OAI-04/OAI-15). Returns `Ok(None)` when the
/// operation cannot be modeled faithfully: an unresolvable parameter
/// `$ref`, a parameter missing `name`/`in`, an unresolvable
@@ -756,12 +793,15 @@ fn parse_operation(
let mut parameters = Vec::new();
if let Some(arr) = raw.get("parameters").and_then(|v| v.as_array()) {
for p in arr {
for (index, p) in arr.iter().enumerate() {
let p = match p.get("$ref").and_then(|r| r.as_str()) {
Some(reference) => match spec.resolve_ref(reference) {
Ok(resolved) => resolved,
Err(_) => return Ok(None),
},
Some(reference) => {
warn_ref_siblings(&format!("parameter[{index}] $ref {reference}"), p);
match spec.resolve_ref(reference) {
Ok(resolved) => resolved,
Err(_) => return Ok(None),
}
}
None => p.clone(),
};
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
@@ -785,10 +825,13 @@ fn parse_operation(
let request_body = match raw.get("requestBody") {
Some(rb) => {
let body = match rb.get("$ref").and_then(|r| r.as_str()) {
Some(reference) => match spec.resolve_ref(reference) {
Ok(resolved) => resolved,
Err(_) => return Ok(None),
},
Some(reference) => {
warn_ref_siblings(&format!("requestBody $ref {reference}"), rb);
match spec.resolve_ref(reference) {
Ok(resolved) => resolved,
Err(_) => return Ok(None),
}
}
None => rb.clone(),
};
if body.get("$ref").is_some() || !body.get("content").is_some_and(|c| c.is_object()) {
@@ -850,11 +893,13 @@ impl ItemParameters {
let Some(arr) = item.get("parameters").and_then(|v| v.as_array()) else {
return Ok(out);
};
for p in arr {
for (index, p) in arr.iter().enumerate() {
let p = match p.get("$ref").and_then(|r| r.as_str()) {
Some(reference) => spec
.resolve_ref(reference)
.map_err(|_| path_style_error(format!("unresolvable $ref: {reference}")))?,
Some(reference) => {
warn_ref_siblings(&format!("path-item parameter[{index}] $ref {reference}"), p);
spec.resolve_ref(reference)
.map_err(|_| path_style_error(format!("unresolvable $ref: {reference}")))?
}
None => p.clone(),
};
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
@@ -1809,6 +1854,36 @@ mod tests {
);
}
// --- OAI-10: $ref sibling keys -------------------------------------------
#[test]
fn ref_sibling_keys_import_with_3_0_reading_and_warn() {
let doc = r##"{
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"},
"components": {"parameters": {
"Id": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}
}},
"paths": {
"/users/{id}": {"get": {
"operationId": "getUser",
"parameters": [
{"$ref": "#/components/parameters/Id", "description": "the user id", "deprecated": false}
],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("sibling keys do not fail the import");
let item = spec.paths.get("/users/{id}").expect("path present");
let param = &item.operations[0].1.parameters[0];
assert_eq!(param.name, "id", "the resolved 3.0 reading wins");
assert_eq!(
param.in_, "path",
"the resolved target's fields are what imports"
);
}
// --- OAI-12: YAML input normalization -----------------------------------
const OAI12_HEADER: &str = r#"