From 688b7e91b252a993fdeed8c8cffb649c611e72af Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Mon, 31 Aug 2026 00:46:14 +0000 Subject: [PATCH] fix(adapters): self-ref'd or content-less requestBody fails import, not silent body-less op (OAI-15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adapters/openapi_spec.rs | 144 ++++++++++++++++++++++++++++++----- 1 file changed, 125 insertions(+), 19 deletions(-) diff --git a/src/adapters/openapi_spec.rs b/src/adapters/openapi_spec.rs index fb4a5c3..a4fc43c 100644 --- a/src/adapters/openapi_spec.rs +++ b/src/adapters/openapi_spec.rs @@ -493,7 +493,8 @@ impl OpenAPISpec { return Err(AdapterError::SchemaParse { message: format!( "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( raw: &Value, spec: &OpenAPISpec, @@ -762,12 +770,6 @@ fn parse_operation( let Some(in_) = p.get("in").and_then(|v| v.as_str()) else { 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)?; let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false); let schema = p.get("schema").cloned(); @@ -780,19 +782,30 @@ fn parse_operation( } } - let request_body = raw.get("requestBody").and_then(|rb| { - let rb = match rb.get("$ref").and_then(|r| r.as_str()) { - Some(reference) => spec.resolve_ref(reference).ok()?, - None => rb.clone(), - }; - let content_obj = rb.get("content")?.as_object()?; - 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); + 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), + }, + None => rb.clone(), + }; + 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(); 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()); } + #[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] fn parameter_ref_to_missing_component_fails_import_loudly() { let doc = r##"{