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:
@@ -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#"
|
||||
|
||||
Reference in New Issue
Block a user