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
9 changes: 9 additions & 0 deletions bin/configs/rust-reqwest-multipart-async.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
generatorName: rust
outputDir: samples/client/others/rust/reqwest/multipart-async
library: reqwest
inputSpec: modules/openapi-generator/src/test/resources/3_0/rust/multipart-file-upload.yaml
templateDir: modules/openapi-generator/src/main/resources/rust
additionalProperties:
supportAsync: true
useSingleRequestParameter: true
packageName: multipart-upload-reqwest-async
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,45 @@ public ModelsMap postProcessModels(ModelsMap objs) {
break;
}
}

// Compute documentation type for each property
// This matches the actual generated code type, including HashSet for uniqueItems
for (CodegenProperty cp : cm.vars) {
String docType;

if (cp.datatypeWithEnum != null && !cp.datatypeWithEnum.isEmpty()) {
// Use enum type if available (e.g., Vec<UniqueItemArray> instead of Vec<String>)
docType = cp.datatypeWithEnum;
} else {
// Use regular dataType
docType = cp.dataType;
}

// Apply uniqueItems logic (matching model.mustache lines 139, 161)
// Arrays with uniqueItems=true use HashSet instead of Vec in the generated code
if (Boolean.TRUE.equals(cp.getUniqueItems()) && docType.startsWith("Vec<")) {
docType = docType.replace("Vec<", "HashSet<");
}

cp.vendorExtensions.put("x-doc-type", docType);

// Determine if this type should have a doc link
// Only local models should link, not external types from std lib or crates
boolean shouldLink = false;
if (cp.complexType != null && !cp.complexType.isEmpty()) {
// Check if it's an external type by looking for known prefixes
String[] externalPrefixes = {"std::", "serde_json::", "uuid::", "chrono::", "url::"};
boolean isExternal = false;
for (String prefix : externalPrefixes) {
if (cp.complexType.startsWith(prefix)) {
isExternal = true;
break;
}
}
shouldLink = !isExternal;
}
cp.vendorExtensions.put("x-should-link", shouldLink);
}
}
// process enum in models
return postProcessModelsEnum(objs);
Expand Down Expand Up @@ -741,7 +780,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
}

// If we use a file body parameter, we need to include the imports and crates for it
// But they should be added only once per file
// But they should be added only once per file
for (var param: operation.bodyParams) {
if (param.isFile && supportAsync && !useAsyncFileStream) {
useAsyncFileStream = true;
Expand All @@ -751,6 +790,18 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
}
}

// Also check form params for file uploads (multipart)
if (!useAsyncFileStream) {
for (var param: operation.formParams) {
if (param.isFile && supportAsync) {
useAsyncFileStream = true;
additionalProperties.put("useAsyncFileStream", Boolean.TRUE);
operation.vendorExtensions.put("useAsyncFileStream", Boolean.TRUE);
break;
}
}
}

// http method verb conversion, depending on client library (e.g. Hyper: PUT => Put, Reqwest: PUT => put)
if (HYPER_LIBRARY.equals(getLibrary())) {
operation.httpMethod = StringUtils.camelize(operation.httpMethod.toLowerCase(Locale.ROOT));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Method | HTTP request | Description
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------{{/-last}}{{/allParams}}
{{#allParams}}
**{{{paramName}}}** | {{^required}}Option<{{/required}}{{#required}}{{#isNullable}}Option<{{/isNullable}}{{/required}}{{#isPrimitiveType}}**{{{dataType}}}**{{/isPrimitiveType}}{{^isPrimitiveType}}[**{{{dataType}}}**]({{{baseType}}}.md){{/isPrimitiveType}}{{^required}}>{{/required}}{{#required}}{{#isNullable}}>{{/isNullable}}{{/required}} | {{{description}}} | {{#required}}[required]{{/required}} |{{#defaultValue}}[default to {{{.}}}]{{/defaultValue}}
**{{{paramName}}}** | {{^required}}Option<{{/required}}{{#required}}{{#isNullable}}Option<{{/isNullable}}{{/required}}{{#isPrimitiveType}}**{{{dataType}}}**{{/isPrimitiveType}}{{^isPrimitiveType}}{{#complexType}}[**{{{dataType}}}**]({{#lambda.pascalcase}}{{{complexType}}}{{/lambda.pascalcase}}.md){{/complexType}}{{^complexType}}[**{{{dataType}}}**]({{#lambda.pascalcase}}{{{dataType}}}{{/lambda.pascalcase}}.md){{/complexType}}{{/isPrimitiveType}}{{^required}}>{{/required}}{{#required}}{{#isNullable}}>{{/isNullable}}{{/required}} | {{{description}}}{{#isInnerEnum}} (enum: {{#allowableValues}}{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}{{/allowableValues}}){{/isInnerEnum}} | {{#required}}[required]{{/required}} |{{#defaultValue}}[default to {{{.}}}]{{/defaultValue}}
{{/allParams}}

### Return type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,25 @@ impl<C: Connect>{{{classname}}} for {{{classname}}}Client<C>
let query_value = s.iter().map(|s| s.to_string()).collect::<Vec<String>>().join(",");
{{/isArray}}
{{^isArray}}
{{#isPrimitiveType}}
let query_value = s.to_string();
{{/isPrimitiveType}}
{{^isPrimitiveType}}
{{#isEnum}}
let query_value = s.to_string();
{{/isEnum}}
{{^isEnum}}
{{#isEnumRef}}
let query_value = s.to_string();
{{/isEnumRef}}
{{^isEnumRef}}
let query_value = match serde_json::to_string(s) {
Ok(value) => value,
Err(e) => return Box::pin(futures::future::err(Error::Serde(e))),
};
{{/isEnumRef}}
{{/isEnum}}
{{/isPrimitiveType}}
{{/isArray}}
req = req.with_query_param("{{{baseName}}}".to_string(), query_value);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,25 @@ impl<C: hyper::client::connect::Connect>{{{classname}}} for {{{classname}}}Clien
let query_value = s.iter().map(|s| s.to_string()).collect::<Vec<String>>().join(",");
{{/isArray}}
{{^isArray}}
{{#isPrimitiveType}}
let query_value = s.to_string();
{{/isPrimitiveType}}
{{^isPrimitiveType}}
{{#isEnum}}
let query_value = s.to_string();
{{/isEnum}}
{{^isEnum}}
{{#isEnumRef}}
let query_value = s.to_string();
{{/isEnumRef}}
{{^isEnumRef}}
let query_value = match serde_json::to_string(s) {
Ok(value) => value,
Err(e) => return Box::pin(futures::future::err(Error::Serde(e))),
};
{{/isEnumRef}}
{{/isEnum}}
{{/isPrimitiveType}}
{{/isArray}}
req = req.with_query_param("{{{baseName}}}".to_string(), query_value);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ impl {{{classname}}} {
}}{{^-last}}, {{/-last}}{{/requiredVars}}) -> {{{classname}}} {
{{{classname}}} {
{{#vars}}
{{{name}}}{{^required}}: None{{/required}}{{#required}}{{#isModel}}{{^avoidBoxedModels}}: {{^isNullable}}Box::new({{{name}}}){{/isNullable}}{{#isNullable}}if let Some(x) = {{{name}}} {Some(Box::new(x))} else {None}{{/isNullable}}{{/avoidBoxedModels}}{{/isModel}}{{/required}},
{{{name}}}{{^required}}: None{{/required}}{{#required}}{{^isEnum}}{{#isModel}}{{^avoidBoxedModels}}: {{^isNullable}}Box::new({{{name}}}){{/isNullable}}{{#isNullable}}if let Some(x) = {{{name}}} {Some(Box::new(x))} else {None}{{/isNullable}}{{/avoidBoxedModels}}{{/isModel}}{{/isEnum}}{{/required}},
{{/vars}}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
{{#vars}}**{{{name}}}** | {{^required}}Option<{{/required}}{{#required}}{{#isNullable}}Option<{{/isNullable}}{{/required}}{{#isPrimitiveType}}**{{{dataType}}}**{{/isPrimitiveType}}{{^isPrimitiveType}}[**{{{dataType}}}**]({{{complexType}}}.md){{/isPrimitiveType}}{{^required}}>{{/required}}{{#required}}{{#isNullable}}>{{/isNullable}}{{/required}} | {{{description}}} | {{^required}}[optional]{{/required}}{{#isReadOnly}}[readonly]{{/isReadOnly}}{{#defaultValue}}[default to {{{.}}}]{{/defaultValue}}
{{#vars}}**{{{name}}}** | {{^required}}Option<{{/required}}{{#required}}{{#isNullable}}Option<{{/isNullable}}{{/required}}{{#vendorExtensions.x-should-link}}[**{{{vendorExtensions.x-doc-type}}}**]({{#lambda.pascalcase}}{{{complexType}}}{{/lambda.pascalcase}}.md){{/vendorExtensions.x-should-link}}{{^vendorExtensions.x-should-link}}**{{{vendorExtensions.x-doc-type}}}**{{/vendorExtensions.x-should-link}}{{^required}}>{{/required}}{{#required}}{{#isNullable}}>{{/isNullable}}{{/required}} | {{{description}}}{{#isInnerEnum}} (enum: {{#allowableValues}}{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}{{/allowableValues}}){{/isInnerEnum}} | {{^required}}[optional]{{/required}}{{#isReadOnly}}[readonly]{{/isReadOnly}}{{#defaultValue}}[default to {{{.}}}]{{/defaultValue}}
{{/vars}}
{{/x-mapped-models}}
{{/vendorExtensions}}
Expand All @@ -35,7 +35,7 @@ Name | Type | Description | Notes

Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
{{#vars}}**{{{name}}}** | {{^required}}Option<{{/required}}{{#required}}{{#isNullable}}Option<{{/isNullable}}{{/required}}{{#isPrimitiveType}}**{{{dataType}}}**{{/isPrimitiveType}}{{^isPrimitiveType}}[**{{{dataType}}}**]({{{complexType}}}.md){{/isPrimitiveType}}{{^required}}>{{/required}}{{#required}}{{#isNullable}}>{{/isNullable}}{{/required}} | {{{description}}} | {{^required}}[optional]{{/required}}{{#isReadOnly}}[readonly]{{/isReadOnly}}{{#defaultValue}}[default to {{{.}}}]{{/defaultValue}}
{{#vars}}**{{{name}}}** | {{^required}}Option<{{/required}}{{#required}}{{#isNullable}}Option<{{/isNullable}}{{/required}}{{#vendorExtensions.x-should-link}}[**{{{vendorExtensions.x-doc-type}}}**]({{#lambda.pascalcase}}{{{complexType}}}{{/lambda.pascalcase}}.md){{/vendorExtensions.x-should-link}}{{^vendorExtensions.x-should-link}}**{{{vendorExtensions.x-doc-type}}}**{{/vendorExtensions.x-should-link}}{{^required}}>{{/required}}{{#required}}{{#isNullable}}>{{/isNullable}}{{/required}} | {{{description}}}{{#isInnerEnum}} (enum: {{#allowableValues}}{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}{{/allowableValues}}){{/isInnerEnum}} | {{^required}}[optional]{{/required}}{{#isReadOnly}}[readonly]{{/isReadOnly}}{{#defaultValue}}[default to {{{.}}}]{{/defaultValue}}
{{/vars}}
{{/oneOf.isEmpty}}
{{^oneOf.isEmpty}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration:
{{^isObject}}
{{^isModel}}
{{^isEnum}}
{{^isEnumRef}}
{{#isPrimitiveType}}
if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} {
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
Expand All @@ -214,7 +215,18 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration:
req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]);
};
{{/isPrimitiveType}}
{{/isEnumRef}}
{{/isEnum}}
{{#isEnum}}
if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} {
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
};
{{/isEnum}}
{{#isEnumRef}}
if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} {
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
};
{{/isEnumRef}}
{{/isModel}}
{{/isObject}}
{{/isNullable}}
Expand Down Expand Up @@ -255,17 +267,22 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration:
req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]);
{{/isModel}}
{{#isEnum}}
req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]);
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
{{/isEnum}}
{{#isEnumRef}}
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
{{/isEnumRef}}
{{^isObject}}
{{^isModel}}
{{^isEnum}}
{{^isEnumRef}}
{{#isPrimitiveType}}
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
{{/isPrimitiveType}}
{{^isPrimitiveType}}
req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]);
{{/isPrimitiveType}}
{{/isEnumRef}}
{{/isEnum}}
{{/isModel}}
{{/isObject}}
Expand Down Expand Up @@ -405,11 +422,19 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration:
{{#supportAsync}}
{{^required}}
if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} {
multipart_form = multipart_form.file("{{{baseName}}}", param_value.as_os_str()).await?;
let file = TokioFile::open(param_value).await?;
let stream = FramedRead::new(file, BytesCodec::new());
let file_name = param_value.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let file_part = reqwest::multipart::Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name);
multipart_form = multipart_form.part("{{{baseName}}}", file_part);
}
{{/required}}
{{#required}}
multipart_form = multipart_form.file("{{{baseName}}}", {{{vendorExtensions.x-rust-param-identifier}}}.as_os_str()).await?;
let file = TokioFile::open(&{{{vendorExtensions.x-rust-param-identifier}}}).await?;
let stream = FramedRead::new(file, BytesCodec::new());
let file_name = {{{vendorExtensions.x-rust-param-identifier}}}.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let file_part = reqwest::multipart::Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name);
multipart_form = multipart_form.part("{{{baseName}}}", file_part);
{{/required}}
{{/supportAsync}}
{{/isFile}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
openapi: 3.0.3
info:
title: Multipart File Upload Test
description: Regression test for async multipart file uploads with tokio::fs
version: 1.0.0
servers:
- url: http://localhost:8080
paths:
/upload/single:
post:
operationId: uploadSingleFile
summary: Upload a single file (required parameter)
description: Tests async multipart file upload with required file parameter
requestBody:
Copy link

@cubic-dev-ai cubic-dev-ai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: requestBody missing required: true, making required multipart file optional

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/test/resources/3_0/rust/multipart-file-upload.yaml, line 14:

<comment>requestBody missing `required: true`, making required multipart file optional</comment>

<file context>
@@ -0,0 +1,123 @@
+      operationId: uploadSingleFile
+      summary: Upload a single file (required parameter)
+      description: Tests async multipart file upload with required file parameter
+      requestBody:
+        required: true
+        content:
</file context>
Fix with Cubic

required: true
content:
multipart/form-data:
schema:
type: object
required:
- file
- description
properties:
description:
type: string
description: File description metadata
file:
type: string
format: binary
description: File to upload
responses:
'200':
description: Upload successful
content:
application/json:
schema:
$ref: '#/components/schemas/UploadResponse'
'400':
description: Bad request

/upload/optional:
post:
operationId: uploadOptionalFile
summary: Upload an optional file
description: Tests async multipart file upload with optional file parameter
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
metadata:
type: string
description: Optional metadata string
file:
type: string
format: binary
description: Optional file to upload
responses:
'200':
description: Upload successful
content:
application/json:
schema:
$ref: '#/components/schemas/UploadResponse'

/upload/multiple-fields:
post:
operationId: uploadMultipleFields
summary: Upload with multiple form fields
description: Tests async multipart with multiple files and text fields
requestBody:
content:
multipart/form-data:
schema:
type: object
required:
- primaryFile
properties:
title:
type: string
description: Upload title
tags:
type: array
items:
type: string
description: Tags for the upload
primaryFile:
type: string
format: binary
description: Primary file (required)
thumbnail:
type: string
format: binary
description: Optional thumbnail file
responses:
'200':
description: Upload successful
content:
application/json:
schema:
$ref: '#/components/schemas/UploadResponse'
'400':
description: Bad request

components:
schemas:
UploadResponse:
type: object
required:
- success
- fileCount
properties:
success:
type: boolean
description: Whether the upload was successful
fileCount:
type: integer
format: int32
description: Number of files uploaded
message:
type: string
description: Optional message about the upload
Loading
Loading