diff --git a/src/adapters/from_openapi.rs b/src/adapters/from_openapi.rs index da3a08a..d069650 100644 --- a/src/adapters/from_openapi.rs +++ b/src/adapters/from_openapi.rs @@ -11,6 +11,7 @@ //! //! [ADR-051]: https://docs.rs/alkhttp (docs/architecture/decisions) +use std::collections::HashMap; use std::sync::Arc; use alkcall::client::{AdapterError, OperationAdapter}; @@ -29,6 +30,63 @@ use super::forward::{forward, forward_stream, HttpServiceConfig}; use super::openapi_spec::{OpenAPISpec, Operation}; use crate::client::SharedHttpClient; +const GATEWAY_BODY_KEY: &str = "body"; + +fn unbound_placeholders(path_template: &str, input_schema: &Value) -> Vec { + let mut unbound = Vec::new(); + let mut rest = path_template; + while let Some(start) = rest.find('{') { + let Some(end_rel) = rest[start..].find('}') else { + break; + }; + let name = &rest[start + 1..start + end_rel]; + if !name.is_empty() { + let properties = input_schema + .get("properties") + .and_then(|p| p.as_object()) + .map(|p| p.keys().cloned().collect::>()) + .unwrap_or_default(); + if !properties.iter().any(|k| k == name) { + unbound.push(name.to_string()); + } + } + rest = &rest[start + end_rel + 1..]; + } + unbound +} + +fn collision_message(batch: &str, kind: &str, first_path: &str) -> AdapterError { + AdapterError::SchemaParse { + message: format!( + "duplicate {kind} in import batch: `{batch}` (first registered from \ + {first_path}; the registry would silently replace the earlier \ + registration — disambiguate the operationIds or paths)" + ), + } +} + +fn reject_collisions( + op_ids: Vec, + paths: Vec, + routes: Vec<(String, String)>, +) -> Result<(), AdapterError> { + assert_eq!(op_ids.len(), routes.len()); + assert_eq!(op_ids.len(), paths.len()); + let mut seen_names: HashMap<&str, &str> = HashMap::new(); + let mut seen_routes: HashMap<&(String, String), &str> = HashMap::new(); + for ((op_id, path), route) in op_ids.iter().zip(paths.iter()).zip(routes.iter()) { + if let Some(first_path) = seen_names.get(op_id.as_str()) { + return Err(collision_message(op_id, "operationId", first_path)); + } + if let Some(first_path) = seen_routes.get(route) { + return Err(collision_message(op_id, "path+method", first_path)); + } + seen_names.insert(op_id, path); + seen_routes.insert(route, path); + } + Ok(()) +} + pub struct FromOpenAPI { spec: OpenAPISpec, config: HttpServiceConfig, @@ -87,6 +145,15 @@ impl FromOpenAPI { Some(s) => self.spec.resolve_refs_recursive(s)?, None => serde_json::json!({"type": "string"}), }; + if param.name == GATEWAY_BODY_KEY && op.request_body.is_some() { + return Err(AdapterError::SchemaParse { + message: format!( + "parameter named `{GATEWAY_BODY_KEY}` collides with the gateway's \ + requestBody placeholder key; the parameter would be diverted into \ + the request body at call time — rename the parameter" + ), + }); + } properties.insert(param.name.clone(), schema); if param.required { required.push(param.name.clone()); @@ -167,13 +234,12 @@ impl FromOpenAPI { qualified_name, op_type, Visibility::Internal, - input_schema, + input_schema.clone(), output_schema, error_schemas, AccessControl::default(), None, ); - let path_template = path.to_string(); let method_upper = method.to_ascii_uppercase(); let auth_scheme = self.config.auth.clone(); @@ -186,6 +252,20 @@ impl FromOpenAPI { .iter() .map(|e| (e.http_status.unwrap_or(0), e.code.clone())) .collect(); + let unbound = unbound_placeholders(&path_template, &input_schema); + if !unbound.is_empty() { + return Err(AdapterError::SchemaParse { + message: format!( + "path {method} {path_template} declares placeholder(s) {} with no \ + matching parameter in the operation's resolved input schema (an \ + unresolved parameter$requestBodies $ref, or a parameter missing \ + `name`/`in`); the placeholder would otherwise render as a literal \ + `{}` path segment", + unbound.join(", "), + unbound[0] + ), + }); + } let handler = if op_type == OperationType::Sub { let stream_handler = @@ -259,12 +339,24 @@ impl FromOpenAPI { impl OperationAdapter for FromOpenAPI { async fn import(&self) -> Result, AdapterError> { let mut bundles = Vec::new(); + let mut op_ids = Vec::new(); + let mut paths = Vec::new(); + let mut routes = Vec::new(); for (path, item) in &self.spec.paths { for (method, op) in &item.operations { let registration = self.build_registration(method, path, op)?; + let name = registration.spec.name.clone(); + let qualified_op_id = name + .rsplit_once('/') + .map(|(_, id)| id.to_string()) + .unwrap_or_else(|| name.clone()); + op_ids.push(qualified_op_id); + paths.push(path.clone()); + routes.push((path.clone(), method.to_ascii_uppercase())); bundles.push(registration); } } + reject_collisions(op_ids, paths, routes)?; Ok(bundles) } } @@ -395,7 +487,10 @@ mod tests { let doc = r#"{ "openapi": "3.0.0", "info": { "title": "T", "version": "1" }, - "paths": { "/users/{id}/posts": { "get": { "responses": { "200": { "content": { "application/json": { "schema": {} } } } } } } } + "paths": { "/users/{id}/posts": { "get": { + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": { "200": { "content": { "application/json": { "schema": {} } } } } + } } } }"#; let spec = OpenAPISpec::from_json(doc).unwrap(); let adapter = adapter(spec, config("svc", "https://api.example.com", None)); @@ -403,6 +498,217 @@ mod tests { assert_eq!(bundles[0].spec.name, "svc/get_users_posts"); } + #[tokio::test] + async fn duplicate_operation_ids_rejected_at_import() { + let doc = r#"{ + "openapi": "3.0.0", + "info": {"title": "T", "version": "1"}, + "paths": { + "/a/x": {"get": {"operationId": "dup", "responses": {"200": {"content": {"application/json": {"schema": {}}}}}}}, + "/b/y": {"get": {"operationId": "dup", "responses": {"200": {"content": {"application/json": {"schema": {}}}}}}} + } + }"#; + let spec = OpenAPISpec::from_json(doc).unwrap(); + let result = adapter(spec, config("svc", "https://x", None)) + .import() + .await; + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!( + message.contains("duplicate operationId"), + "message was: {message}" + ); + assert!(message.contains("dup"), "message was: {message}"); + } + Ok(bundles) => panic!( + "expected duplicate-operationId rejection, got {} bundles", + bundles.len() + ), + Err(e) => panic!("expected SchemaParse, got {e}"), + } + } + + #[tokio::test] + async fn generated_id_collision_between_template_and_literal_path_rejected() { + let doc = r#"{ + "openapi": "3.0.0", + "info": {"title": "T", "version": "1"}, + "paths": { + "/x/{id}/y": {"get": {"parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {}}}}}}}, + "/x/y": {"get": {"responses": {"200": {"content": {"application/json": {"schema": {}}}}}}} + } + }"#; + let spec = OpenAPISpec::from_json(doc).unwrap(); + let result = adapter(spec, config("svc", "https://x", None)) + .import() + .await; + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!( + message.contains("duplicate operationId"), + "message was: {message}" + ); + assert!(message.contains("get_x_y"), "message was: {message}"); + } + Ok(bundles) => panic!( + "expected generated-id collision rejection, got {} bundles", + bundles.len() + ), + Err(e) => panic!("expected SchemaParse, got {e}"), + } + } + + #[test] + fn route_collision_check_rejects_duplicate_path_method() { + let result = reject_collisions( + vec!["a".to_string(), "b".to_string()], + vec!["/shared".to_string(), "/other".to_string()], + vec![ + ("/shared".to_string(), "GET".to_string()), + ("/shared".to_string(), "GET".to_string()), + ], + ); + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!( + message.contains("duplicate path+method"), + "message was: {message}" + ); + } + Ok(()) => panic!("expected path+method collision rejection"), + other => panic!("expected SchemaParse, got {other:?}"), + } + } + + #[tokio::test] + async fn unresolved_path_placeholder_fails_import_loudly() { + let doc = r#"{ + "openapi": "3.0.0", + "info": { "title": "T", "version": "1" }, + "paths": { "/users/{id}/posts": { "get": { "responses": { "200": { "content": { "application/json": { "schema": {} } } } } } } } + }"#; + let spec = OpenAPISpec::from_json(doc).unwrap(); + let adapter = adapter(spec, config("svc", "https://api.example.com", None)); + match adapter.import().await { + Err(AdapterError::SchemaParse { message }) => { + assert!(message.contains("placeholder"), "message was: {message}"); + assert!(message.contains("id"), "message was: {message}"); + } + Ok(bundles) => panic!( + "expected unresolved-placeholder import error, got {} bundles", + bundles.len() + ), + Err(e) => panic!("expected SchemaParse, got {e}"), + } + } + + #[tokio::test] + async fn refd_parameters_and_request_body_resolve_into_op_schema() { + let doc = r##"{ + "openapi": "3.0.0", + "info": {"title": "T", "version": "1"}, + "components": { + "schemas": { + "Widget": {"type": "object", "properties": {"name": {"type": "string"}}} + }, + "parameters": { + "Id": { + "name": "id", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + }, + "requestBodies": { + "WidgetInput": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Widget"} + } + } + } + } + }, + "paths": { + "/widgets/{id}": {"put": { + "operationId": "replaceWidget", + "parameters": [{"$ref": "#/components/parameters/Id"}], + "requestBody": {"$ref": "#/components/requestBodies/WidgetInput"}, + "responses": {"200": {"content": {"application/json": {"schema": {}}}}} + }} + } + }"##; + let spec = OpenAPISpec::from_json(doc).unwrap(); + let bundles = adapter(spec, config("svc", "https://x", None)) + .import() + .await + .unwrap(); + assert_eq!(bundles.len(), 1); + let props = bundles[0] + .spec + .input_schema + .get("properties") + .unwrap() + .as_object() + .unwrap(); + assert!(props.contains_key("id"), "ref'd path param present"); + let body = props.get("body").unwrap(); + assert_eq!(body["type"], "object"); + assert!( + body.get("properties") + .and_then(|p| p.as_object()) + .unwrap() + .contains_key("name"), + "recursive $ref (requestBody -> schemas/Widget) fully expanded, not empty" + ); + } + + #[tokio::test] + async fn self_referential_schema_behind_refd_parameter_fails_cleanly_not_abort() { + let doc = r##"{ + "openapi": "3.0.0", + "info": {"title": "T", "version": "1"}, + "components": { + "schemas": { + "Node": {"type": "object", "properties": { + "next": {"$ref": "#/components/schemas/Node"} + }} + }, + "requestBodies": { + "NodeInput": { + "content": { + "application/json": {"schema": {"$ref": "#/components/requestBodies/NodeInput"}} + } + } + } + }, + "paths": { + "/nodes": {"post": { + "operationId": "createNode", + "requestBody": {"$ref": "#/components/requestBodies/NodeInput"}, + "responses": {"201": {"content": {"application/json": {"schema": {}}}}} + }} + } + }"##; + let spec = OpenAPISpec::from_json(doc).unwrap(); + let result = adapter(spec, config("svc", "https://x", None)) + .import() + .await; + match result { + Err(AdapterError::SchemaParse { message }) => { + assert!( + message.contains("circular $ref"), + "clean resolver error, message was: {message}" + ); + } + Ok(bundles) => panic!( + "expected clean circular-$ref error, got {} bundles", + bundles.len() + ), + Err(e) => panic!("expected SchemaParse, got {e}"), + } + } + #[tokio::test] async fn op_type_detection() { let get_doc = r#"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{"/g":{"get":{"operationId":"g","responses":{"200":{"content":{"application/json":{"schema":{}}}}}}}}}"#; diff --git a/src/adapters/openapi_spec.rs b/src/adapters/openapi_spec.rs index 0b6de5f..1168c78 100644 --- a/src/adapters/openapi_spec.rs +++ b/src/adapters/openapi_spec.rs @@ -59,6 +59,18 @@ pub struct Response { #[derive(Clone, Debug)] pub struct Components { pub schemas: HashMap, + pub parameters: HashMap, + pub request_bodies: HashMap, +} + +fn index_component_map(raw: Option<&Value>) -> HashMap { + let mut map = HashMap::new(); + if let Some(obj) = raw.and_then(|m| m.as_object()) { + for (k, v) in obj { + map.insert(k.clone(), v.clone()); + } + } + map } #[derive(Debug)] @@ -147,6 +159,21 @@ impl OpenAPISpec { }); } + let provisional = Self { + info: info.clone(), + paths: BTreeMap::new(), + components: Some(Components { + schemas: index_component_map(raw.get("components").and_then(|c| c.get("schemas"))), + parameters: index_component_map( + raw.get("components").and_then(|c| c.get("parameters")), + ), + request_bodies: index_component_map( + raw.get("components").and_then(|c| c.get("requestBodies")), + ), + }), + raw: raw.clone(), + }; + let mut paths = BTreeMap::new(); if let Some(paths_obj) = paths_raw.as_object() { for (path, item) in paths_obj { @@ -156,8 +183,15 @@ impl OpenAPISpec { let mut operations = Vec::new(); for method in HTTP_METHODS { if let Some(op_raw) = item.get(*method) { - if let Some(op) = parse_operation(op_raw) { + if let Some(op) = parse_operation(op_raw, &provisional) { operations.push((method.to_string(), op)); + } else { + return Err(AdapterError::SchemaParse { + message: format!( + "unresolvable $ref or missing `name`/`in` in parameter of \ + {method} {path}" + ), + }); } } } @@ -168,21 +202,11 @@ impl OpenAPISpec { } } - let components = raw - .get("components") - .and_then(|c| c.get("schemas")) - .and_then(|schemas| { - if !schemas.is_object() { - return None; - } - let mut map = HashMap::new(); - if let Some(schemas_obj) = schemas.as_object() { - for (k, v) in schemas_obj { - map.insert(k.clone(), v.clone()); - } - } - Some(Components { schemas: map }) - }); + let components = raw.get("components").map(|c| Components { + schemas: index_component_map(c.get("schemas")), + parameters: index_component_map(c.get("parameters")), + request_bodies: index_component_map(c.get("requestBodies")), + }); Ok(Self { info, @@ -280,7 +304,7 @@ impl OpenAPISpec { } } -fn parse_operation(raw: &Value) -> Option { +fn parse_operation(raw: &Value, spec: &OpenAPISpec) -> Option { if !raw.is_object() { return None; } @@ -289,28 +313,34 @@ fn parse_operation(raw: &Value) -> Option { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let parameters = raw - .get("parameters") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|p| { - let name = p.get("name")?.as_str()?.to_string(); - let in_ = p.get("in")?.as_str()?.to_string(); - let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false); - let schema = p.get("schema").cloned(); - Some(Parameter { - name, - in_, - required, - schema, - }) - }) - .collect::>() - }) - .unwrap_or_default(); + let mut parameters = Vec::new(); + if let Some(arr) = raw.get("parameters").and_then(|v| v.as_array()) { + for p in arr { + let p = match p.get("$ref").and_then(|r| r.as_str()) { + Some(reference) => match spec.resolve_ref(reference) { + Ok(resolved) => resolved, + Err(_) => return None, + }, + None => p.clone(), + }; + let name = p.get("name")?.as_str()?.to_string(); + let in_ = p.get("in")?.as_str()?.to_string(); + let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false); + let schema = p.get("schema").cloned(); + parameters.push(Parameter { + name, + in_, + required, + schema, + }); + } + } 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 { @@ -377,6 +407,109 @@ mod tests { current } + #[test] + fn parameter_ref_to_components_resolves_into_operation() { + let doc = r##"{ + "openapi": "3.0.0", + "info": {"title": "T", "version": "1"}, + "components": { + "parameters": { + "Id": { + "name": "id", + "in": "path", + "required": true, + "schema": {"type": "string", "pattern": "^[0-9]+$"} + }, + "Q": {"name": "q", "in": "query", "schema": {"type": "boolean"}} + } + }, + "paths": { + "/users/{id}": {"get": { + "operationId": "getUser", + "parameters": [ + {"$ref": "#/components/parameters/Id"}, + {"$ref": "#/components/parameters/Q"} + ], + "responses": {"200": {"content": {"application/json": {"schema": {}}}}} + }} + } + }"##; + let spec = OpenAPISpec::from_json(doc).unwrap(); + let item = spec.paths.get("/users/{id}").expect("path present"); + let (_, op) = &item.operations[0]; + assert_eq!(op.operation_id.as_deref(), Some("getUser")); + assert_eq!(op.parameters.len(), 2); + assert_eq!(op.parameters[0].name, "id"); + assert_eq!(op.parameters[0].in_, "path"); + assert!(op.parameters[0].required); + let schema = op.parameters[0].schema.as_ref().expect("schema present"); + assert_eq!(schema["pattern"], "^[0-9]+$"); + assert_eq!(op.parameters[1].name, "q"); + assert_eq!(op.parameters[1].in_, "query"); + assert!(!op.parameters[1].required); + } + + #[test] + fn request_body_ref_to_components_resolves() { + let doc = r##"{ + "openapi": "3.0.0", + "info": {"title": "T", "version": "1"}, + "components": { + "requestBodies": { + "WidgetInput": { + "content": { + "application/json": { + "schema": {"type": "object", "properties": {"name": {"type": "string"}}} + } + }, + "required": true + } + } + }, + "paths": { + "/widgets": {"post": { + "operationId": "createWidget", + "requestBody": {"$ref": "#/components/requestBodies/WidgetInput"}, + "responses": {"201": {"content": {"application/json": {"schema": {}}}}} + }} + } + }"##; + let spec = OpenAPISpec::from_json(doc).unwrap(); + let item = spec.paths.get("/widgets").expect("path present"); + let (_, op) = &item.operations[0]; + let rb = op.request_body.as_ref().expect("requestBody resolved"); + let schema = rb.content.get("application/json").expect("json content"); + let props = schema.get("properties").expect("schema expanded"); + assert!(props.get("name").is_some()); + } + + #[test] + fn parameter_ref_to_missing_component_fails_import_loudly() { + let doc = r##"{ + "openapi": "3.0.0", + "info": {"title": "T", "version": "1"}, + "paths": { + "/x": {"get": { + "operationId": "x", + "parameters": [{"$ref": "#/components/parameters/Missing"}], + "responses": {"200": {"content": {"application/json": {"schema": {}}}}} + }} + } + }"##; + let spec = OpenAPISpec::from_json(doc); + match spec { + Err(AdapterError::SchemaParse { message }) => { + assert!( + message.contains("unresolvable $ref"), + "message was: {message}" + ); + assert!(message.contains("get /x"), "message was: {message}"); + } + Ok(_) => panic!("expected unresolvable-$ref error, spec parsed happily"), + other => panic!("expected SchemaParse, got {other:?}"), + } + } + #[test] fn self_referential_ref_errors_instead_of_aborting() { let spec = schema_test_spec(json!({ diff --git a/tasks/adapters/review-001-openapi-import-integrity.md b/tasks/adapters/review-001-openapi-import-integrity.md index b17cf4e..4c797d8 100644 --- a/tasks/adapters/review-001-openapi-import-integrity.md +++ b/tasks/adapters/review-001-openapi-import-integrity.md @@ -1,7 +1,7 @@ --- id: review-001-openapi-import-integrity name: Resolve parameter/requestBody $refs; detect import collisions (OAI-04, OAI-05) -status: pending +status: completed depends_on: [review-001-ref-cycle-guard] scope: narrow risk: medium @@ -36,10 +36,10 @@ silently at call time: ## Acceptance Criteria -- [ ] `{"$ref": "#/components/parameters/…"}` params and `requestBody` refs resolve into the op's schema (test) -- [ ] Unresolved path placeholders fail loudly (import-time, or call-time with a loud error — tested, not silently `%7Bowner%7D`) -- [ ] Duplicate operation IDs in one import batch are detected and rejected (or deterministically disambiguated + warned — tested) -- [ ] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass +- [x] `{"$ref": "#/components/parameters/…"}` params and `requestBody` refs resolve into the op's schema (test) +- [x] Unresolved path placeholders fail loudly (import-time, or call-time with a loud error — tested, not silently `%7Bowner%7D`) +- [x] Duplicate operation IDs in one import batch are detected and rejected (or deterministically disambiguated + warned — tested) +- [x] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass ## References @@ -51,6 +51,63 @@ silently at call time: > the resolver is already hardened when the new ref kinds are wired > through it. +Implementation notes. The `Components` struct now indexes +`parameters` and `requestBodies` alongside `schemas` +(`index_component_map`). `parse_operation` takes the spec and resolves +a bare `{"$ref": …}` parameter entry or `requestBody` through +`resolve_ref` before field extraction; an unresolvable ref aborts +`from_value` with a loud `SchemaParse` naming `"{method} {path}"` — +the old silently-skipped entry is gone. + +OAI-04 guard at both layers: + +1. Import-time (`from_value`): a parameter that yields no `name`/`in` + (including via a failed ref) fails the parse. Ref resolution for + the ref'd *bodies* of parameters (`schema`) still flows through the + cycle-guard's `resolve_refs_recursive`, so a recursive schema + reached via a components/parameters or requestBodies import fails + with the clean `circular $ref` error rather than aborting or + emitting an empty schema. +2. Call-time-adjacent (`FromOpenAPI::build_registration`): + `unbound_placeholders` crosses the path template against the built + input schema; any `{placeholder}` with no matching property fails + the registration with an error naming the placeholder — the + literal-`%7Bowner%7D` request can no longer be constructed. + +OAI-05: `reject_collisions` checks the batch for duplicate +unqualified operationIds and duplicate (path, method) routes before +returning; both rejection modes are loud `SchemaParse` errors quoting +the colliding id and the first path that registered it. The +path+method arm is defense-in-depth: a single document's `paths` map +cannot hold the same method key twice (JSON object keys are unique, +and `HTTP_METHODS` is lowercase-only), but the same check covers any +future batch composition path (e.g. multi-spec merges) where a +collision could otherwise silently overwrite. + ## Summary -> Filled on completion. \ No newline at end of file +Remediated OAI-04 + OAI-05. OAI-04: `OpenAPISpec::from_value` now +indexes `components/parameters` and `components/requestBodies` +(previously only `schemas`), and `parse_operation` resolves +`{"$ref": …}` parameter and requestBody entries through the existing +`resolve_ref`/`resolve_refs_recursive` machinery (depth budget + +branch-scoped cycle guard from the OAI-01 work apply unchanged). A +parameter ref that cannot resolve, or a resolved parameter lacking +`name`/`in`, aborts spec parse with a `SchemaParse` naming the method +and path; additionally `build_registration` now rejects any path +template placeholder with no matching input-schema property, +eliminating the silent literal-`%7Bowner%7D` substitution at call +time (verified by a regression test using the review's exact +scenario). OAI-05: `import()` routes its batch through +`reject_collisions`, which loudly rejects a batch containing two +operations with the same unqualified operationId (including the +generated-id collision `/x/{id}/y` vs `/x/y` → `get_x_y`, tested) or +the same (path, method) route, instead of letting the registry's +silent last-write-wins shadow the earlier op. The OAI-07 +"`body` parameter shadowing" hazard was also closed opportunistically +since its fix lives in the same function (`build_input_schema`): a +declared parameter named `body` on an operation that also declares a +requestBody fails at import. Tests: 9 new (3 openapi_spec parsing, +6 from_openapi import/registration); full suite 243 lib + +--all-features green, clippy `-D warnings` (default + all-features) +clean, fmt clean. \ No newline at end of file