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
1 change: 1 addition & 0 deletions examples/server-anthropic-messages/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ futures-util = "0.3"
# Pulled in by the generated types — see src/gen/REQUIRED_DEPS.toml
# after running `openapi-to-rust generate`.
base64 = "0.22"
bytes = { version = "1", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }
url = "2"
304 changes: 263 additions & 41 deletions src/analysis.rs

Large diffs are not rendered by default.

561 changes: 528 additions & 33 deletions src/client_generator.rs

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions src/registry_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,10 @@ impl CodeGenerator {
quote! { BodyContentType::FormUrlEncoded },
quote! { Some(#schema_name) },
),
RequestBodyContent::Multipart => {
(quote! { BodyContentType::Multipart }, quote! { None })
}
RequestBodyContent::Multipart { schema_name, .. } => (
quote! { BodyContentType::Multipart },
quote! { Some(#schema_name) },
),
RequestBodyContent::OctetStream { .. } => {
(quote! { BodyContentType::OctetStream }, quote! { None })
}
Expand Down
1,065 changes: 1,026 additions & 39 deletions src/server/codegen.rs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/server/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ pub(crate) fn prepare_validation_bundle(
}
| RequestBodyContent::FormUrlEncoded {
validation_schema, ..
}
| RequestBodyContent::Multipart {
validation_schema, ..
} => Some(validation_schema),
_ => None,
};
Expand Down
3 changes: 3 additions & 0 deletions src/type_mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ pub fn collect_generated_dep_requirements<'a>(
}
if uses("axum::") {
let mut features = vec!["json"];
if uses("axum::extract::Multipart") {
features.push("multipart");
}
if uses("axum::response::sse::") {
features.push("tokio");
}
Expand Down
68 changes: 65 additions & 3 deletions tests/generation_requirements_test.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use openapi_to_rust::config::ServerSection;
use openapi_to_rust::config::{ServerSection, ServerValidationSection};
use openapi_to_rust::streaming::{StreamingConfig, StreamingEndpoint};
use openapi_to_rust::type_mapping::{DurationStrategy, TypeMappingConfig};
use openapi_to_rust::type_mapping::{BinaryStrategy, DurationStrategy, TypeMappingConfig};
use openapi_to_rust::{CodeGenerator, GeneratorConfig, RetryConfig, SchemaAnalyzer, TypeMapper};
use serde_json::json;
use std::collections::BTreeSet;
Expand Down Expand Up @@ -49,9 +49,15 @@ fn requirements_spec() -> serde_json::Value {
"responses": { "204": { "description": "ok" } }
}
},
"/upload": {
"/upload/{id}.json": {
"post": {
"operationId": "uploadPayload",
"parameters": [{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string" }
}],
"requestBody": {
"required": true,
"content": {
Expand Down Expand Up @@ -266,6 +272,62 @@ fn disabled_sse_feature_does_not_emit_streaming_code_or_dependencies() {
assert!(!dependency_names(&result).contains("futures-util"));
}

#[test]
fn multipart_server_enables_axum_multipart_feature() {
let result = compile_case(
"multipart-server",
GeneratorConfig {
enable_async_client: false,
enable_sse_client: false,
tracing_enabled: false,
server: Some(ServerSection {
framework: "axum".into(),
operations: vec!["uploadPayload".into()],
prune_models: false,
validation: Default::default(),
}),
..Default::default()
},
);
let axum = result
.required_deps
.iter()
.find(|dependency| dependency.crate_name == "axum")
.expect("axum dependency");
assert_eq!(axum.features, vec!["json", "multipart"]);
}

#[test]
fn multipart_client_and_server_compile_for_every_binary_strategy() {
for (name, binary) in [
("multipart-binary-bytes", BinaryStrategy::Bytes),
("multipart-binary-vec", BinaryStrategy::VecU8),
("multipart-binary-string", BinaryStrategy::String),
] {
compile_case(
name,
GeneratorConfig {
enable_sse_client: false,
tracing_enabled: false,
types: TypeMappingConfig {
binary,
..Default::default()
},
server: Some(ServerSection {
framework: "axum".into(),
operations: vec!["uploadPayload".into()],
prune_models: false,
validation: ServerValidationSection {
enabled: false,
..Default::default()
},
}),
..Default::default()
},
);
}
}

#[test]
fn every_generation_mode_compiles_from_its_exact_dependency_fragment() {
let types = compile_case(
Expand Down
21 changes: 16 additions & 5 deletions tests/operation_extraction_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,11 +424,22 @@ fn test_extract_multipart_body() {
.operations
.get("uploadFile")
.expect("uploadFile operation not found");
assert!(op.request_body.is_some());
assert!(matches!(
op.request_body.as_ref().unwrap(),
RequestBodyContent::Multipart
));
let request_body = op.request_body.as_ref().unwrap();
let RequestBodyContent::Multipart {
schema_name,
media_type,
validation_schema,
} = request_body
else {
panic!("expected typed multipart request body, got {request_body:?}");
};
assert_eq!(schema_name, "UploadFileRequest");
assert_eq!(media_type, "multipart/form-data");
assert_eq!(
validation_schema.pointer("/properties/file/format"),
Some(&serde_json::Value::String("binary".to_string()))
);
assert_eq!(request_body.schema_name(), Some("UploadFileRequest"));
}

#[test]
Expand Down
46 changes: 27 additions & 19 deletions tests/operation_generation_test.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use openapi_to_rust::SchemaAnalyzer;
use openapi_to_rust::analysis::{OperationInfo, RequestBodyContent, SchemaAnalysis};
use openapi_to_rust::generator::{CodeGenerator, GeneratorConfig};
use serde_json::json;
use std::collections::BTreeMap;

fn create_test_config() -> GeneratorConfig {
Expand Down Expand Up @@ -815,28 +817,34 @@ fn test_generate_multipart_operation() {
let config = create_test_config();
let generator = CodeGenerator::new(config);

let operation = OperationInfo {
operation_id: "uploadFile".to_string(),
method: "POST".to_string(),
path: "/upload".to_string(),
summary: None,
description: None,
request_body: Some(RequestBodyContent::Multipart),
response_schemas: BTreeMap::new(),
parameters: vec![],
request_body_required: true,
supports_streaming: false,
stream_parameter: None,
tags: Vec::new(),
};

let analysis = create_test_analysis_with_operations(vec![operation]);
let analysis = SchemaAnalyzer::new(json!({
"openapi": "3.1.0",
"info": { "title": "multipart", "version": "1" },
"paths": { "/upload": { "post": {
"operationId": "uploadFile",
"requestBody": { "required": true, "content": {
"multipart/form-data": { "schema": {
"type": "object",
"required": ["file", "count"],
"properties": {
"file": { "type": "string", "format": "binary" },
"count": { "type": "integer" },
"display-name": { "type": "string" }
}
}}
}},
"responses": { "204": { "description": "ok" } }
}}}
}))
.unwrap()
.analyze()
.unwrap();
let result = generator.generate_operation_methods(&analysis);
let result_str = result.to_string();

// Verify parameter is reqwest multipart form
assert!(result_str.contains("form : reqwest :: multipart :: Form"));
// Verify .multipart(form) call
assert!(result_str.contains("request : UploadFileRequest"));
assert!(result_str.contains("Part :: bytes"));
assert!(result_str.contains("\"display-name\""));
assert!(result_str.contains(". multipart (form)"));
}

Expand Down
127 changes: 127 additions & 0 deletions tests/server_body_validation_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,98 @@ fn xml_request_media_is_accepted_as_text_during_server_generation() {
.expect("application/xml request bodies are text-decodable");
}

#[test]
fn typed_multipart_request_generates_axum_extraction() {
let spec = json!({
"openapi": "3.1.0",
"info": { "title": "multipart body", "version": "1" },
"paths": { "/upload": { "post": {
"operationId": "uploadFile",
"requestBody": { "required": true, "content": {
"multipart/form-data": { "schema": {
"type": "object",
"additionalProperties": false,
"required": ["file", "count", "enabled"],
"properties": {
"file": { "type": "string", "format": "binary" },
"count": { "type": "integer", "format": "uint64", "minimum": 1 },
"enabled": { "type": "boolean" },
"display-name": { "type": "string", "minLength": 2 }
}
}}
}},
"responses": { "204": { "description": "accepted" } }
}}}
});
let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap();
let server = ServerSection {
framework: "axum".into(),
operations: vec!["uploadFile".into()],
prune_models: true,
validation: Default::default(),
};
let config = GeneratorConfig {
server: Some(server.clone()),
..Default::default()
};

let generated = ServerCodegen::new(&config, &analysis, &server)
.generate()
.expect("flat typed multipart schemas should generate");
let api = &generated
.iter()
.find(|file| file.path.ends_with("api.rs"))
.expect("api.rs")
.content;
let router = &generated
.iter()
.find(|file| file.path.ends_with("router.rs"))
.expect("router.rs")
.content;
assert!(api.contains("UploadFileRequest"), "{api}");
assert!(router.contains("::axum::extract::Multipart"), "{router}");
assert!(router.contains("DefaultBodyLimit::max"), "{router}");
assert!(router.contains("display-name"), "{router}");
assert!(router.contains("parse::<u64>"), "{router}");
assert!(router.contains("__multipart_binary_file"), "{router}");
assert!(!router.contains("bytes.iter()"), "{router}");
}

#[test]
fn typed_multipart_additional_properties_are_rejected() {
let spec = json!({
"openapi": "3.1.0",
"info": { "title": "typed multipart map", "version": "1" },
"paths": { "/upload": { "post": {
"operationId": "uploadMap",
"requestBody": { "required": true, "content": {
"multipart/form-data": { "schema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"additionalProperties": { "type": "string" }
}}
}},
"responses": { "204": { "description": "accepted" } }
}}}
});
let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap();
let server = ServerSection {
framework: "axum".into(),
operations: vec!["uploadMap".into()],
prune_models: true,
validation: Default::default(),
};
let config = GeneratorConfig {
server: Some(server.clone()),
..Default::default()
};
let error = ServerCodegen::new(&config, &analysis, &server)
.generate()
.unwrap_err()
.to_string();
assert!(error.contains("additionalProperties"), "{error}");
}

#[test]
fn unsupported_request_media_is_rejected_during_server_generation() {
let spec = json!({
Expand Down Expand Up @@ -155,6 +247,41 @@ fn unsupported_request_media_is_rejected_during_server_generation() {
assert!(error.contains("application/x-proprietary"), "{error}");
}

#[test]
fn cyclic_multipart_aliases_are_rejected_without_recursing() {
let spec = json!({
"openapi": "3.1.0",
"info": { "title": "cyclic multipart", "version": "1" },
"paths": { "/upload": { "post": {
"operationId": "uploadCycle",
"requestBody": { "required": true, "content": {
"multipart/form-data": { "schema": { "$ref": "#/components/schemas/A" } }
}},
"responses": { "204": { "description": "unused" } }
}}},
"components": { "schemas": {
"A": { "$ref": "#/components/schemas/B" },
"B": { "$ref": "#/components/schemas/A" }
}}
});
let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap();
let server = ServerSection {
framework: "axum".into(),
operations: vec!["uploadCycle".into()],
prune_models: true,
validation: Default::default(),
};
let config = GeneratorConfig {
server: Some(server.clone()),
..Default::default()
};
let error = ServerCodegen::new(&config, &analysis, &server)
.generate()
.unwrap_err()
.to_string();
assert!(error.contains("reference cycle"), "{error}");
}

#[test]
fn schema_less_request_content_is_rejected_during_server_generation() {
let spec = json!({
Expand Down
Loading
Loading