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:
+118
-12
@@ -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()) {
|
||||||
|
Some(reference) => match spec.resolve_ref(reference) {
|
||||||
|
Ok(resolved) => resolved,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
},
|
||||||
None => rb.clone(),
|
None => rb.clone(),
|
||||||
};
|
};
|
||||||
let content_obj = rb.get("content")?.as_object()?;
|
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();
|
let mut content = BTreeMap::new();
|
||||||
for (k, v) in content_obj {
|
for (k, v) in content_obj {
|
||||||
let schema = v.get("schema").cloned().unwrap_or(Value::Null);
|
let schema = v.get("schema").cloned().unwrap_or(Value::Null);
|
||||||
content.insert(k.clone(), schema);
|
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##"{
|
||||||
|
|||||||
Reference in New Issue
Block a user