Skip to content

Commit ae42568

Browse files
authored
[Rust] Enum Query Parameter Serialization Fixes (#22683)
* [Rust] Enum Query Parameter Serialization Fixes Adds tests to ensure this won't regress again. Also fixes some other compile errors with Box<> and file uploads. * Remove duplicate query param integration tests from petstore samples * re-gen samples * fix enum boxing tests * stream files * samples * doc generator fix & snapshot * doc generation fixes, update samples * another attempt to fix the doc generator * improve doc generation - don't try link to internal models, and fixing links missing in some scenarios the rust doc generator will be the death of me * also fix hyper * applying same fix to hyper * snapshot fixes
1 parent 58b12ba commit ae42568

251 files changed

Lines changed: 5184 additions & 153 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
generatorName: rust
2+
outputDir: samples/client/others/rust/reqwest/multipart-async
3+
library: reqwest
4+
inputSpec: modules/openapi-generator/src/test/resources/3_0/rust/multipart-file-upload.yaml
5+
templateDir: modules/openapi-generator/src/main/resources/rust
6+
additionalProperties:
7+
supportAsync: true
8+
useSingleRequestParameter: true
9+
packageName: multipart-upload-reqwest-async

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustClientCodegen.java

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,45 @@ public ModelsMap postProcessModels(ModelsMap objs) {
368368
break;
369369
}
370370
}
371+
372+
// Compute documentation type for each property
373+
// This matches the actual generated code type, including HashSet for uniqueItems
374+
for (CodegenProperty cp : cm.vars) {
375+
String docType;
376+
377+
if (cp.datatypeWithEnum != null && !cp.datatypeWithEnum.isEmpty()) {
378+
// Use enum type if available (e.g., Vec<UniqueItemArray> instead of Vec<String>)
379+
docType = cp.datatypeWithEnum;
380+
} else {
381+
// Use regular dataType
382+
docType = cp.dataType;
383+
}
384+
385+
// Apply uniqueItems logic (matching model.mustache lines 139, 161)
386+
// Arrays with uniqueItems=true use HashSet instead of Vec in the generated code
387+
if (Boolean.TRUE.equals(cp.getUniqueItems()) && docType.startsWith("Vec<")) {
388+
docType = docType.replace("Vec<", "HashSet<");
389+
}
390+
391+
cp.vendorExtensions.put("x-doc-type", docType);
392+
393+
// Determine if this type should have a doc link
394+
// Only local models should link, not external types from std lib or crates
395+
boolean shouldLink = false;
396+
if (cp.complexType != null && !cp.complexType.isEmpty()) {
397+
// Check if it's an external type by looking for known prefixes
398+
String[] externalPrefixes = {"std::", "serde_json::", "uuid::", "chrono::", "url::"};
399+
boolean isExternal = false;
400+
for (String prefix : externalPrefixes) {
401+
if (cp.complexType.startsWith(prefix)) {
402+
isExternal = true;
403+
break;
404+
}
405+
}
406+
shouldLink = !isExternal;
407+
}
408+
cp.vendorExtensions.put("x-should-link", shouldLink);
409+
}
371410
}
372411
// process enum in models
373412
return postProcessModelsEnum(objs);
@@ -741,7 +780,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
741780
}
742781

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

793+
// Also check form params for file uploads (multipart)
794+
if (!useAsyncFileStream) {
795+
for (var param: operation.formParams) {
796+
if (param.isFile && supportAsync) {
797+
useAsyncFileStream = true;
798+
additionalProperties.put("useAsyncFileStream", Boolean.TRUE);
799+
operation.vendorExtensions.put("useAsyncFileStream", Boolean.TRUE);
800+
break;
801+
}
802+
}
803+
}
804+
754805
// http method verb conversion, depending on client library (e.g. Hyper: PUT => Put, Reqwest: PUT => put)
755806
if (HYPER_LIBRARY.equals(getLibrary())) {
756807
operation.httpMethod = StringUtils.camelize(operation.httpMethod.toLowerCase(Locale.ROOT));

modules/openapi-generator/src/main/resources/rust/api_doc.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Method | HTTP request | Description
2525
Name | Type | Description | Required | Notes
2626
------------- | ------------- | ------------- | ------------- | -------------{{/-last}}{{/allParams}}
2727
{{#allParams}}
28-
**{{{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}}
28+
**{{{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}}
2929
{{/allParams}}
3030

3131
### Return type

modules/openapi-generator/src/main/resources/rust/hyper/api.mustache

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,25 @@ impl<C: Connect>{{{classname}}} for {{{classname}}}Client<C>
7878
let query_value = s.iter().map(|s| s.to_string()).collect::<Vec<String>>().join(",");
7979
{{/isArray}}
8080
{{^isArray}}
81+
{{#isPrimitiveType}}
82+
let query_value = s.to_string();
83+
{{/isPrimitiveType}}
84+
{{^isPrimitiveType}}
85+
{{#isEnum}}
86+
let query_value = s.to_string();
87+
{{/isEnum}}
88+
{{^isEnum}}
89+
{{#isEnumRef}}
90+
let query_value = s.to_string();
91+
{{/isEnumRef}}
92+
{{^isEnumRef}}
8193
let query_value = match serde_json::to_string(s) {
8294
Ok(value) => value,
8395
Err(e) => return Box::pin(futures::future::err(Error::Serde(e))),
8496
};
97+
{{/isEnumRef}}
98+
{{/isEnum}}
99+
{{/isPrimitiveType}}
85100
{{/isArray}}
86101
req = req.with_query_param("{{{baseName}}}".to_string(), query_value);
87102
}

modules/openapi-generator/src/main/resources/rust/hyper0x/api.mustache

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,25 @@ impl<C: hyper::client::connect::Connect>{{{classname}}} for {{{classname}}}Clien
7777
let query_value = s.iter().map(|s| s.to_string()).collect::<Vec<String>>().join(",");
7878
{{/isArray}}
7979
{{^isArray}}
80+
{{#isPrimitiveType}}
81+
let query_value = s.to_string();
82+
{{/isPrimitiveType}}
83+
{{^isPrimitiveType}}
84+
{{#isEnum}}
85+
let query_value = s.to_string();
86+
{{/isEnum}}
87+
{{^isEnum}}
88+
{{#isEnumRef}}
89+
let query_value = s.to_string();
90+
{{/isEnumRef}}
91+
{{^isEnumRef}}
8092
let query_value = match serde_json::to_string(s) {
8193
Ok(value) => value,
8294
Err(e) => return Box::pin(futures::future::err(Error::Serde(e))),
8395
};
96+
{{/isEnumRef}}
97+
{{/isEnum}}
98+
{{/isPrimitiveType}}
8499
{{/isArray}}
85100
req = req.with_query_param("{{{baseName}}}".to_string(), query_value);
86101
}

modules/openapi-generator/src/main/resources/rust/model.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ impl {{{classname}}} {
167167
}}{{^-last}}, {{/-last}}{{/requiredVars}}) -> {{{classname}}} {
168168
{{{classname}}} {
169169
{{#vars}}
170-
{{{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}},
170+
{{{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}},
171171
{{/vars}}
172172
}
173173
}

modules/openapi-generator/src/main/resources/rust/model_doc.mustache

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
Name | Type | Description | Notes
2424
------------ | ------------- | ------------- | -------------
25-
{{#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}}
25+
{{#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}}
2626
{{/vars}}
2727
{{/x-mapped-models}}
2828
{{/vendorExtensions}}
@@ -35,7 +35,7 @@ Name | Type | Description | Notes
3535

3636
Name | Type | Description | Notes
3737
------------ | ------------- | ------------- | -------------
38-
{{#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}}
38+
{{#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}}
3939
{{/vars}}
4040
{{/oneOf.isEmpty}}
4141
{{^oneOf.isEmpty}}

modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration:
204204
{{^isObject}}
205205
{{^isModel}}
206206
{{^isEnum}}
207+
{{^isEnumRef}}
207208
{{#isPrimitiveType}}
208209
if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} {
209210
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
@@ -214,7 +215,18 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration:
214215
req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]);
215216
};
216217
{{/isPrimitiveType}}
218+
{{/isEnumRef}}
217219
{{/isEnum}}
220+
{{#isEnum}}
221+
if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} {
222+
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
223+
};
224+
{{/isEnum}}
225+
{{#isEnumRef}}
226+
if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} {
227+
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
228+
};
229+
{{/isEnumRef}}
218230
{{/isModel}}
219231
{{/isObject}}
220232
{{/isNullable}}
@@ -255,17 +267,22 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration:
255267
req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]);
256268
{{/isModel}}
257269
{{#isEnum}}
258-
req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]);
270+
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
259271
{{/isEnum}}
272+
{{#isEnumRef}}
273+
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
274+
{{/isEnumRef}}
260275
{{^isObject}}
261276
{{^isModel}}
262277
{{^isEnum}}
278+
{{^isEnumRef}}
263279
{{#isPrimitiveType}}
264280
req_builder = req_builder.query(&[("{{{baseName}}}", &param_value.to_string())]);
265281
{{/isPrimitiveType}}
266282
{{^isPrimitiveType}}
267283
req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]);
268284
{{/isPrimitiveType}}
285+
{{/isEnumRef}}
269286
{{/isEnum}}
270287
{{/isModel}}
271288
{{/isObject}}
@@ -405,11 +422,19 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration:
405422
{{#supportAsync}}
406423
{{^required}}
407424
if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} {
408-
multipart_form = multipart_form.file("{{{baseName}}}", param_value.as_os_str()).await?;
425+
let file = TokioFile::open(param_value).await?;
426+
let stream = FramedRead::new(file, BytesCodec::new());
427+
let file_name = param_value.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
428+
let file_part = reqwest::multipart::Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name);
429+
multipart_form = multipart_form.part("{{{baseName}}}", file_part);
409430
}
410431
{{/required}}
411432
{{#required}}
412-
multipart_form = multipart_form.file("{{{baseName}}}", {{{vendorExtensions.x-rust-param-identifier}}}.as_os_str()).await?;
433+
let file = TokioFile::open(&{{{vendorExtensions.x-rust-param-identifier}}}).await?;
434+
let stream = FramedRead::new(file, BytesCodec::new());
435+
let file_name = {{{vendorExtensions.x-rust-param-identifier}}}.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
436+
let file_part = reqwest::multipart::Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name);
437+
multipart_form = multipart_form.part("{{{baseName}}}", file_part);
413438
{{/required}}
414439
{{/supportAsync}}
415440
{{/isFile}}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Multipart File Upload Test
4+
description: Regression test for async multipart file uploads with tokio::fs
5+
version: 1.0.0
6+
servers:
7+
- url: http://localhost:8080
8+
paths:
9+
/upload/single:
10+
post:
11+
operationId: uploadSingleFile
12+
summary: Upload a single file (required parameter)
13+
description: Tests async multipart file upload with required file parameter
14+
requestBody:
15+
required: true
16+
content:
17+
multipart/form-data:
18+
schema:
19+
type: object
20+
required:
21+
- file
22+
- description
23+
properties:
24+
description:
25+
type: string
26+
description: File description metadata
27+
file:
28+
type: string
29+
format: binary
30+
description: File to upload
31+
responses:
32+
'200':
33+
description: Upload successful
34+
content:
35+
application/json:
36+
schema:
37+
$ref: '#/components/schemas/UploadResponse'
38+
'400':
39+
description: Bad request
40+
41+
/upload/optional:
42+
post:
43+
operationId: uploadOptionalFile
44+
summary: Upload an optional file
45+
description: Tests async multipart file upload with optional file parameter
46+
requestBody:
47+
content:
48+
multipart/form-data:
49+
schema:
50+
type: object
51+
properties:
52+
metadata:
53+
type: string
54+
description: Optional metadata string
55+
file:
56+
type: string
57+
format: binary
58+
description: Optional file to upload
59+
responses:
60+
'200':
61+
description: Upload successful
62+
content:
63+
application/json:
64+
schema:
65+
$ref: '#/components/schemas/UploadResponse'
66+
67+
/upload/multiple-fields:
68+
post:
69+
operationId: uploadMultipleFields
70+
summary: Upload with multiple form fields
71+
description: Tests async multipart with multiple files and text fields
72+
requestBody:
73+
content:
74+
multipart/form-data:
75+
schema:
76+
type: object
77+
required:
78+
- primaryFile
79+
properties:
80+
title:
81+
type: string
82+
description: Upload title
83+
tags:
84+
type: array
85+
items:
86+
type: string
87+
description: Tags for the upload
88+
primaryFile:
89+
type: string
90+
format: binary
91+
description: Primary file (required)
92+
thumbnail:
93+
type: string
94+
format: binary
95+
description: Optional thumbnail file
96+
responses:
97+
'200':
98+
description: Upload successful
99+
content:
100+
application/json:
101+
schema:
102+
$ref: '#/components/schemas/UploadResponse'
103+
'400':
104+
description: Bad request
105+
106+
components:
107+
schemas:
108+
UploadResponse:
109+
type: object
110+
required:
111+
- success
112+
- fileCount
113+
properties:
114+
success:
115+
type: boolean
116+
description: Whether the upload was successful
117+
fileCount:
118+
type: integer
119+
format: int32
120+
description: Number of files uploaded
121+
message:
122+
type: string
123+
description: Optional message about the upload

0 commit comments

Comments
 (0)