Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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),
Expand All @@ -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) {
Expand Down Expand Up @@ -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<crate::openapi::RequestBody> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
}
44 changes: 44 additions & 0 deletions tests/operation_extraction_test.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use openapi_to_rust::analysis::{RequestBodyContent, SchemaAnalyzer};
use openapi_to_rust::{CodeGenerator, GeneratorConfig};

#[test]
fn test_extract_simple_get() {
Expand Down Expand Up @@ -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 =
Expand Down
Loading