fix(adapters): self-ref'd or content-less requestBody fails import, not silent body-less op (OAI-15)

A requestBody $ref that could not resolve (or resolved to a shape
without a content map) previously turned into a content-less Operation
registered silently — every call would INVALID_INPUT on the gateway
body key. parse_operation now treats an unresolvable request-body ref
and a resolved-but-content-less body the same unfaithful-modeling way
as an unresolvable parameter ref: the operation fails import via the
OAI-04 'unresolvable $ref' arm, whose message now names the
requestBody case (OAI-15) alongside the parameter one.

Tests: self-ref requestBody, missing-component requestBody ref, and
content-less component requestBody each fail import loudly.
This commit is contained in:
2026-08-31 00:46:14 +00:00
parent 9a5b4e7936
commit 688b7e91b2
+125 -19
View File
@@ -493,7 +493,8 @@ impl OpenAPISpec {
return Err(AdapterError::SchemaParse { return Err(AdapterError::SchemaParse {
message: format!( message: format!(
"unresolvable $ref or missing `name`/`in` in parameter of \ "unresolvable $ref or missing `name`/`in` in parameter of \
{method} {path}" {method} {path}, or an unresolvable/content-less `requestBody` \
on the operation (review 001 OAI-04, review 002 OAI-15)"
), ),
}); });
} }
@@ -734,6 +735,13 @@ fn count_nodes(value: &Value) -> usize {
} }
} }
/// 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
/// `requestBody` `$ref`, or a resolved `requestBody` that still carries
/// a top-level `$ref` or lacks `content` — a body-less op would
/// register silently and fail every call with `INVALID_INPUT` on
/// `body` (review 002 OAI-15).
fn parse_operation( fn parse_operation(
raw: &Value, raw: &Value,
spec: &OpenAPISpec, spec: &OpenAPISpec,
@@ -762,12 +770,6 @@ fn parse_operation(
let Some(in_) = p.get("in").and_then(|v| v.as_str()) else { let Some(in_) = p.get("in").and_then(|v| v.as_str()) else {
return Ok(None); return Ok(None);
}; };
// OAI-06: non-default `style`/`explode` forms change how arrays
// and objects serialize on the wire (the adapter emits the
// form/simple default — repeated keys for query arrays). A
// parameter declaring a different serialization would silently
// mis-serialize upstream (`"[1,2]"`-style), so it fails import
// with an error naming the parameter and feature.
check_parameter_style(name, in_, &p)?; check_parameter_style(name, in_, &p)?;
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false); let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
let schema = p.get("schema").cloned(); let schema = p.get("schema").cloned();
@@ -780,19 +782,30 @@ fn parse_operation(
} }
} }
let request_body = raw.get("requestBody").and_then(|rb| { let request_body = match raw.get("requestBody") {
let rb = match rb.get("$ref").and_then(|r| r.as_str()) { Some(rb) => {
Some(reference) => spec.resolve_ref(reference).ok()?, let body = match rb.get("$ref").and_then(|r| r.as_str()) {
None => rb.clone(), Some(reference) => match spec.resolve_ref(reference) {
}; Ok(resolved) => resolved,
let content_obj = rb.get("content")?.as_object()?; Err(_) => return Ok(None),
let mut content = BTreeMap::new(); },
for (k, v) in content_obj { None => rb.clone(),
let schema = v.get("schema").cloned().unwrap_or(Value::Null); };
content.insert(k.clone(), schema); if body.get("$ref").is_some() || !body.get("content").is_some_and(|c| c.is_object()) {
return Ok(None);
}
let Some(content_obj) = body.get("content").and_then(|v| v.as_object()) else {
return Ok(None);
};
let mut content = BTreeMap::new();
for (k, v) in content_obj {
let schema = v.get("schema").cloned().unwrap_or(Value::Null);
content.insert(k.clone(), schema);
}
Some(RequestBody { content })
} }
Some(RequestBody { content }) None => None,
}); };
let mut responses = BTreeMap::new(); let mut responses = BTreeMap::new();
if let Some(resp_obj) = raw.get("responses").and_then(|v| v.as_object()) { if let Some(resp_obj) = raw.get("responses").and_then(|v| v.as_object()) {
@@ -1028,6 +1041,99 @@ mod tests {
assert!(props.get("name").is_some()); assert!(props.get("name").is_some());
} }
#[test]
fn request_body_self_ref_fails_import_not_silent_bodyless_op() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/widgets": {"post": {
"operationId": "createWidget",
"requestBody": {"$ref": "#/paths/~1widgets/post/requestBody"},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("requestBody"),
"the error must name the requestBody: {message}"
);
assert!(message.contains("OAI-15"), "message was: {message}");
}
Ok(spec) => {
let body = &spec.paths["/widgets"].operations[0].1.request_body;
assert!(
body.is_none(),
"a self-ref'd requestBody must not silently import as body-less (OAI-15)"
);
panic!("self-$ref'd requestBody must fail import loudly (OAI-15)");
}
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn request_body_ref_to_missing_component_fails_import_loudly() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/widgets": {"post": {
"operationId": "createWidget",
"requestBody": {"$ref": "#/components/requestBodies/Missing"},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("requestBody") || message.contains("unresolvable"),
"message was: {message}"
);
assert!(message.contains("post /widgets"), "message was: {message}");
}
Ok(_) => panic!("unresolvable requestBody $ref must fail import loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn content_less_request_body_fails_import_not_silent_bodyless_op() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"components": {
"requestBodies": {
"DescriptionOnly": {"description": "no content map"}
}
},
"paths": {
"/widgets": {"post": {
"operationId": "createWidget",
"requestBody": {"$ref": "#/components/requestBodies/DescriptionOnly"},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("requestBody") || message.contains("unresolvable"),
"message was: {message}"
);
}
Ok(spec) => {
let body = &spec.paths["/widgets"].operations[0].1.request_body;
assert!(body.is_none(), "content-less body must not silently drop");
panic!("content-less requestBody must fail import loudly (OAI-15)");
}
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test] #[test]
fn parameter_ref_to_missing_component_fails_import_loudly() { fn parameter_ref_to_missing_component_fails_import_loudly() {
let doc = r##"{ let doc = r##"{