From 58bff402b019b1c2b13e465eff96c90a85e8af00 Mon Sep 17 00:00:00 2001 From: Barnaby Keene Date: Tue, 28 Jul 2026 20:39:11 +0100 Subject: [PATCH] add support for $ref's in request bodies --- src/analysis.rs | 61 ++++++++++++++++++- .../component_request_body_ref.json | 39 ++++++++++++ tests/operation_extraction_test.rs | 44 +++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/operation_extraction/component_request_body_ref.json diff --git a/src/analysis.rs b/src/analysis.rs index a1da0ba..13e4864 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -4474,6 +4474,11 @@ impl SchemaAnalyzer { .as_ref() .and_then(|path_item| path_item.get(method.to_ascii_lowercase())) .cloned(); + let request_body = operation + .request_body + .as_ref() + .map(|request_body| self.resolve_request_body(request_body)) + .transpose()?; let mut op_info = OperationInfo { operation_id: operation_id.to_string(), method: method.to_uppercase(), @@ -4482,8 +4487,7 @@ impl SchemaAnalyzer { description: operation.description.clone(), request_body: None, // Per OAS 3.x ยง"Request Body Object", `required` defaults to false. - request_body_required: operation - .request_body + request_body_required: request_body .as_ref() .and_then(|rb| rb.required) .unwrap_or(false), @@ -4496,7 +4500,7 @@ impl SchemaAnalyzer { let mut operation_responses = BTreeMap::new(); // Extract request body schema with content-type awareness - if let Some(request_body) = &operation.request_body { + if let Some(request_body) = &request_body { use crate::openapi::{is_form_urlencoded_media_type, is_json_media_type}; if let Some((content_type, maybe_schema)) = request_body.best_content() { op_info.request_body = if is_json_media_type(content_type) { @@ -4770,6 +4774,57 @@ impl SchemaAnalyzer { Ok((op_info, operation_responses)) } + /// Resolve a local reusable Request Body Object through its JSON Pointer. + fn resolve_request_body( + &self, + request_body: &crate::openapi::RequestBody, + ) -> Result { + let mut current = request_body.clone(); + let mut visited = HashSet::new(); + while let Some(reference) = current.reference.clone() { + if !visited.insert(reference.clone()) { + return Err(GeneratorError::CircularDependency(format!( + "request body reference {reference}" + ))); + } + + let pointer = reference.strip_prefix('#').ok_or_else(|| { + GeneratorError::UnresolvedReference(format!( + "external request body reference `{reference}` is not supported" + )) + })?; + if !pointer.is_empty() && !pointer.starts_with('/') { + return Err(GeneratorError::UnresolvedReference(format!( + "request body reference `{reference}` is not a local JSON Pointer" + ))); + } + let value = self.openapi_spec.pointer(pointer).ok_or_else(|| { + GeneratorError::UnresolvedReference(format!( + "request body reference `{reference}` does not exist" + )) + })?; + let object = value.as_object().ok_or_else(|| { + GeneratorError::InvalidSchema(format!( + "request body reference `{reference}` must target an object" + )) + })?; + if !["$ref", "description", "required", "content"] + .iter() + .any(|field| object.contains_key(*field)) + { + return Err(GeneratorError::InvalidSchema(format!( + "request body reference `{reference}` does not target a structurally compatible OpenAPI Request Body Object" + ))); + } + current = serde_json::from_value(value.clone()).map_err(|error| { + GeneratorError::InvalidSchema(format!( + "request body reference `{reference}` is not a valid OpenAPI Request Body Object: {error}" + )) + })?; + } + Ok(current) + } + /// Resolve a local reusable Response Object through its JSON Pointer. /// /// Real-world documents occasionally store a structurally valid Response diff --git a/tests/fixtures/operation_extraction/component_request_body_ref.json b/tests/fixtures/operation_extraction/component_request_body_ref.json new file mode 100644 index 0000000..23088fb --- /dev/null +++ b/tests/fixtures/operation_extraction/component_request_body_ref.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Component request body reference", + "version": "1.0.0" + }, + "paths": { + "/execute": { + "post": { + "operationId": "execute", + "requestBody": { + "$ref": "#/components/requestBodies/Execute" + }, + "responses": { + "200": { + "description": "Executed" + } + } + } + } + }, + "components": { + "requestBodies": { + "Execute": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Program" + } + } + } + } + }, + "schemas": { + "Program": {} + } + } +} diff --git a/tests/operation_extraction_test.rs b/tests/operation_extraction_test.rs index 90a40f6..d2fd9eb 100644 --- a/tests/operation_extraction_test.rs +++ b/tests/operation_extraction_test.rs @@ -1,4 +1,5 @@ use openapi_to_rust::analysis::{RequestBodyContent, SchemaAnalyzer}; +use openapi_to_rust::{CodeGenerator, GeneratorConfig}; #[test] fn test_extract_simple_get() { @@ -68,6 +69,49 @@ fn test_extract_post_with_body() { insta::assert_yaml_snapshot!("post_with_body_operations", analysis.operations); } +#[test] +fn component_request_body_reference_is_resolved() { + let spec = std::fs::read_to_string( + "tests/fixtures/operation_extraction/component_request_body_ref.json", + ) + .unwrap(); + let spec_value: serde_json::Value = serde_json::from_str(&spec).unwrap(); + + let mut analyzer = SchemaAnalyzer::new(spec_value).unwrap(); + let analysis = analyzer.analyze().unwrap(); + let op = &analysis.operations["execute"]; + + assert!(op.request_body_required); + assert_eq!( + op.request_body.as_ref().and_then(|body| body.schema_name()), + Some("Program") + ); + let body_schema = match op.request_body.as_ref().unwrap() { + RequestBodyContent::Json { + media_type, + validation_schema, + .. + } => { + assert_eq!(media_type, "application/json"); + validation_schema + } + other => panic!("expected JSON body, got {other:?}"), + }; + assert_eq!(body_schema["$ref"], "#/components/schemas/Program"); + + let generated = CodeGenerator::new(GeneratorConfig::default()) + .generate_operation_methods(&analysis) + .to_string(); + assert!( + generated.contains("request : Program"), + "generated client method must retain the referenced request body: {generated}" + ); + assert!( + generated.contains("serde_json :: to_vec (& request)"), + "generated client must serialize the referenced request body: {generated}" + ); +} + #[test] fn test_extract_path_params() { let spec =