Skip to content

Commit 3eb3988

Browse files
timvwGoopher Maijenburg
authored andcommitted
fix(rust): Add anyOf support to Rust client generator (OpenAPITools#21896)
* feat(rust): Add anyOf support to Rust client generator This commit adds support for anyOf schemas in the Rust client generator by treating them similarly to oneOf schemas, generating untagged enums instead of empty structs. The implementation reuses the existing oneOf logic since Rust's serde untagged enum will deserialize to the first matching variant, which aligns well with anyOf semantics where one or more schemas must match. Fixes the issue where anyOf schemas would generate empty unusable structs. * test(rust): Add test for anyOf support This commit adds a test case to verify that anyOf schemas generate proper untagged enums instead of empty structs in the Rust client generator. The test includes: - A test OpenAPI spec with anyOf schemas - Unit test that verifies the generated code structure - Assertions to ensure enums are created instead of empty structs * Fix anyOf support for Rust generator - Fixed template closing tag issue that prevented anyOf schemas from generating enums - Changed {{/composedSchemas.oneOf}} to {{/composedSchemas}} at line 262 - Put #[serde(untagged)] and pub enum on same line for test compatibility - Fixed TestUtils.linearize() method replacing spaces with literal '\s' string The Rust generator already converts anyOf to oneOf for processing, but the template wasn't correctly handling these converted schemas. Now anyOf schemas generate proper untagged enums, matching the expected behavior for oneOf schemas without discriminators. * fix(rust): maintain multi-line formatting for serde attributes in oneOf/anyOf enums - Keep #[serde(untagged)] on separate line from pub enum for better readability - Update test assertions to use two separate checks instead of linearize() - Ensures generated Rust code maintains consistent formatting with existing samples - Preserves the original multi-line attribute style preferred in Rust ecosystem
1 parent 13d0a5b commit 3eb3988

File tree

4 files changed

+220
-17
lines changed

4 files changed

+220
-17
lines changed

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,69 @@ public CodegenModel fromModel(String name, Schema model) {
307307
mdl.getComposedSchemas().setOneOf(newOneOfs);
308308
}
309309

310+
// Handle anyOf schemas similarly to oneOf
311+
// This is pragmatic since Rust's untagged enum will deserialize to the first matching variant
312+
if (mdl.getComposedSchemas() != null && mdl.getComposedSchemas().getAnyOf() != null
313+
&& !mdl.getComposedSchemas().getAnyOf().isEmpty()) {
314+
315+
List<CodegenProperty> newAnyOfs = mdl.getComposedSchemas().getAnyOf().stream()
316+
.map(CodegenProperty::clone)
317+
.collect(Collectors.toList());
318+
List<Schema> schemas = ModelUtils.getInterfaces(model);
319+
if (newAnyOfs.size() != schemas.size()) {
320+
// For safety reasons, this should never happen unless there is an error in the code
321+
throw new RuntimeException("anyOf size does not match the model");
322+
}
323+
324+
Map<String, String> refsMapping = Optional.ofNullable(model.getDiscriminator())
325+
.map(Discriminator::getMapping).orElse(Collections.emptyMap());
326+
327+
// Reverse mapped references to use as baseName for anyOf, but different keys may point to the same $ref.
328+
// Thus, we group them by the value
329+
Map<String, List<String>> mappedNamesByRef = refsMapping.entrySet().stream()
330+
.collect(Collectors.groupingBy(Map.Entry::getValue,
331+
Collectors.mapping(Map.Entry::getKey, Collectors.toList())
332+
));
333+
334+
for (int i = 0; i < newAnyOfs.size(); i++) {
335+
CodegenProperty anyOf = newAnyOfs.get(i);
336+
Schema schema = schemas.get(i);
337+
338+
if (mappedNamesByRef.containsKey(schema.get$ref())) {
339+
// prefer mapped names if present
340+
// remove mapping not in order not to reuse for the next occurrence of the ref
341+
List<String> names = mappedNamesByRef.get(schema.get$ref());
342+
String mappedName = names.remove(0);
343+
anyOf.setBaseName(mappedName);
344+
anyOf.setName(toModelName(mappedName));
345+
} else if (!org.apache.commons.lang3.StringUtils.isEmpty(schema.get$ref())) {
346+
// use $ref if it's reference
347+
String refName = ModelUtils.getSimpleRef(schema.get$ref());
348+
if (refName != null) {
349+
String modelName = toModelName(refName);
350+
anyOf.setName(modelName);
351+
anyOf.setBaseName(refName);
352+
}
353+
} else if (anyOf.isArray) {
354+
// If the type is an array, extend the name with the inner type to prevent name collisions
355+
// in case multiple arrays with different types are defined. If the user has manually specified
356+
// a name, use that name instead.
357+
String collectionWithTypeName = toModelName(schema.getType()) + anyOf.containerTypeMapped + anyOf.items.dataType;
358+
String anyOfName = Optional.ofNullable(schema.getTitle()).orElse(collectionWithTypeName);
359+
anyOf.setName(anyOfName);
360+
}
361+
else {
362+
// In-placed type (primitive), because there is no mapping or ref for it.
363+
// use camelized `title` if present, otherwise use `type`
364+
String anyOfName = Optional.ofNullable(schema.getTitle()).orElseGet(schema::getType);
365+
anyOf.setName(toModelName(anyOfName));
366+
}
367+
}
368+
369+
// Set anyOf as oneOf for template processing since we want the same output
370+
mdl.getComposedSchemas().setOneOf(newAnyOfs);
371+
}
372+
310373
return mdl;
311374
}
312375

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

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,40 @@ impl Default for {{classname}} {
121121
{{!-- for non-enum schemas --}}
122122
{{^isEnum}}
123123
{{^discriminator}}
124+
{{#composedSchemas}}
125+
{{#oneOf}}
126+
{{#-first}}
127+
{{! Model with composedSchemas.oneOf - generate enum}}
128+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
129+
#[serde(untagged)]
130+
pub enum {{classname}} {
131+
{{/-first}}
132+
{{/oneOf}}
133+
{{/composedSchemas}}
134+
{{#composedSchemas}}
135+
{{#oneOf}}
136+
{{#description}}
137+
/// {{{.}}}
138+
{{/description}}
139+
{{{name}}}({{#isModel}}{{^avoidBoxedModels}}Box<{{/avoidBoxedModels}}{{/isModel}}{{{dataType}}}{{#isModel}}{{^avoidBoxedModels}}>{{/avoidBoxedModels}}{{/isModel}}),
140+
{{/oneOf}}
141+
{{/composedSchemas}}
142+
{{#composedSchemas}}
143+
{{#oneOf}}
144+
{{#-last}}
145+
}
146+
147+
impl Default for {{classname}} {
148+
fn default() -> Self {
149+
{{#oneOf}}{{#-first}}Self::{{{name}}}(Default::default()){{/-first}}{{/oneOf}}
150+
}
151+
}
152+
{{/-last}}
153+
{{/oneOf}}
154+
{{^oneOf}}
155+
{{! composedSchemas exists but no oneOf - generate normal struct}}
124156
{{#vendorExtensions.x-rust-has-byte-array}}#[serde_as]
125-
{{/vendorExtensions.x-rust-has-byte-array}}{{#oneOf.isEmpty}}#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
157+
{{/vendorExtensions.x-rust-has-byte-array}}#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
126158
pub struct {{{classname}}} {
127159
{{#vars}}
128160
{{#description}}
@@ -172,29 +204,62 @@ impl {{{classname}}} {
172204
}
173205
}
174206
}
175-
{{/oneOf.isEmpty}}
176-
{{^oneOf.isEmpty}}
177-
{{! TODO: add other vars that are not part of the oneOf}}
178-
{{#description}}
179-
/// {{{.}}}
180-
{{/description}}
181-
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
182-
#[serde(untagged)]
183-
pub enum {{classname}} {
184-
{{#composedSchemas.oneOf}}
207+
{{/oneOf}}
208+
{{/composedSchemas}}
209+
{{^composedSchemas}}
210+
{{! Normal struct without composedSchemas}}
211+
{{#vendorExtensions.x-rust-has-byte-array}}#[serde_as]
212+
{{/vendorExtensions.x-rust-has-byte-array}}#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
213+
pub struct {{{classname}}} {
214+
{{#vars}}
185215
{{#description}}
186216
/// {{{.}}}
187217
{{/description}}
188-
{{{name}}}({{#isModel}}{{^avoidBoxedModels}}Box<{{/avoidBoxedModels}}{{/isModel}}{{{dataType}}}{{#isModel}}{{^avoidBoxedModels}}>{{/avoidBoxedModels}}{{/isModel}}),
189-
{{/composedSchemas.oneOf}}
218+
{{#isByteArray}}
219+
{{#vendorExtensions.isMandatory}}#[serde_as(as = "serde_with::base64::Base64")]{{/vendorExtensions.isMandatory}}{{^vendorExtensions.isMandatory}}#[serde_as(as = "{{^serdeAsDoubleOption}}Option{{/serdeAsDoubleOption}}{{#serdeAsDoubleOption}}super::DoubleOption{{/serdeAsDoubleOption}}<serde_with::base64::Base64>")]{{/vendorExtensions.isMandatory}}
220+
{{/isByteArray}}
221+
#[serde(rename = "{{{baseName}}}"{{^required}}{{#isNullable}}, default{{^isByteArray}}, with = "::serde_with::rust::double_option"{{/isByteArray}}{{/isNullable}}{{/required}}{{^required}}, skip_serializing_if = "Option::is_none"{{/required}}{{#required}}{{#isNullable}}, deserialize_with = "Option::deserialize"{{/isNullable}}{{/required}})]
222+
pub {{{name}}}: {{!
223+
### Option Start
224+
}}{{#isNullable}}Option<{{/isNullable}}{{^required}}Option<{{/required}}{{!
225+
### Enums
226+
}}{{#isEnum}}{{#isArray}}{{#uniqueItems}}std::collections::HashSet<{{/uniqueItems}}{{^uniqueItems}}Vec<{{/uniqueItems}}{{/isArray}}{{{enumName}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{!
227+
### Non-Enums Start
228+
}}{{^isEnum}}{{!
229+
### Models
230+
}}{{#isModel}}{{^avoidBoxedModels}}Box<{{/avoidBoxedModels}}{{{dataType}}}{{^avoidBoxedModels}}>{{/avoidBoxedModels}}{{/isModel}}{{!
231+
### Primative datatypes
232+
}}{{^isModel}}{{#isByteArray}}Vec<u8>{{/isByteArray}}{{^isByteArray}}{{{dataType}}}{{/isByteArray}}{{/isModel}}{{!
233+
### Non-Enums End
234+
}}{{/isEnum}}{{!
235+
### Option End (and trailing comma)
236+
}}{{#isNullable}}>{{/isNullable}}{{^required}}>{{/required}},
237+
{{/vars}}
190238
}
191239
192-
impl Default for {{classname}} {
193-
fn default() -> Self {
194-
{{#composedSchemas.oneOf}}{{#-first}}Self::{{{name}}}(Default::default()){{/-first}}{{/composedSchemas.oneOf}}
240+
impl {{{classname}}} {
241+
{{#description}}
242+
/// {{{.}}}
243+
{{/description}}
244+
pub fn new({{#requiredVars}}{{{name}}}: {{!
245+
### Option Start
246+
}}{{#isNullable}}Option<{{/isNullable}}{{!
247+
### Enums
248+
}}{{#isEnum}}{{#isArray}}{{#uniqueItems}}std::collections::HashSet<{{/uniqueItems}}{{^uniqueItems}}Vec<{{/uniqueItems}}{{/isArray}}{{{enumName}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{!
249+
### Non-Enums
250+
}}{{^isEnum}}{{#isByteArray}}Vec<u8>{{/isByteArray}}{{^isByteArray}}{{{dataType}}}{{/isByteArray}}{{/isEnum}}{{!
251+
### Option End
252+
}}{{#isNullable}}>{{/isNullable}}{{!
253+
### Comma for next arguement
254+
}}{{^-last}}, {{/-last}}{{/requiredVars}}) -> {{{classname}}} {
255+
{{{classname}}} {
256+
{{#vars}}
257+
{{{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}},
258+
{{/vars}}
259+
}
195260
}
196261
}
197-
{{/oneOf.isEmpty}}
262+
{{/composedSchemas}}
198263
{{/discriminator}}
199264
{{/isEnum}}
200265
{{!-- for properties that are of enum type --}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,4 +271,37 @@ public void testMultipleArrayTypesEnum() throws IOException {
271271
TestUtils.assertFileExists(outputPath);
272272
TestUtils.assertFileContains(outputPath, enumSpec);
273273
}
274+
275+
@Test
276+
public void testAnyOfSupport() throws IOException {
277+
Path target = Files.createTempDirectory("test-anyof");
278+
final CodegenConfigurator configurator = new CodegenConfigurator()
279+
.setGeneratorName("rust")
280+
.setInputSpec("src/test/resources/3_0/rust/rust-anyof-test.yaml")
281+
.setSkipOverwrite(false)
282+
.setOutputDir(target.toAbsolutePath().toString().replace("\\", "/"));
283+
List<File> files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate();
284+
files.forEach(File::deleteOnExit);
285+
286+
// Test that ModelIdentifier generates an untagged enum, not an empty struct
287+
Path modelIdentifierPath = Path.of(target.toString(), "/src/models/model_identifier.rs");
288+
TestUtils.assertFileExists(modelIdentifierPath);
289+
290+
// Should generate an untagged enum
291+
TestUtils.assertFileContains(modelIdentifierPath, "#[serde(untagged)]");
292+
TestUtils.assertFileContains(modelIdentifierPath, "pub enum ModelIdentifier");
293+
294+
// Should have String variant (for anyOf with string types)
295+
TestUtils.assertFileContains(modelIdentifierPath, "String(String)");
296+
297+
// Should NOT generate an empty struct
298+
TestUtils.assertFileNotContains(modelIdentifierPath, "pub struct ModelIdentifier {");
299+
TestUtils.assertFileNotContains(modelIdentifierPath, "pub fn new()");
300+
301+
// Test AnotherAnyOfTest with mixed types
302+
Path anotherTestPath = Path.of(target.toString(), "/src/models/another_any_of_test.rs");
303+
TestUtils.assertFileExists(anotherTestPath);
304+
TestUtils.assertFileContains(anotherTestPath, "#[serde(untagged)]");
305+
TestUtils.assertFileContains(anotherTestPath, "pub enum AnotherAnyOfTest");
306+
}
274307
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Rust anyOf Test
4+
version: 1.0.0
5+
paths:
6+
/model:
7+
get:
8+
responses:
9+
'200':
10+
description: OK
11+
content:
12+
application/json:
13+
schema:
14+
$ref: '#/components/schemas/TestResponse'
15+
components:
16+
schemas:
17+
TestResponse:
18+
type: object
19+
properties:
20+
model:
21+
$ref: '#/components/schemas/ModelIdentifier'
22+
status:
23+
type: string
24+
ModelIdentifier:
25+
description: Model identifier that can be a string or specific enum value
26+
anyOf:
27+
- type: string
28+
description: Any model name as string
29+
- type: string
30+
enum:
31+
- gpt-4
32+
- gpt-3.5-turbo
33+
- dall-e-3
34+
description: Known model enum values
35+
AnotherAnyOfTest:
36+
description: Another test case with different types
37+
anyOf:
38+
- type: string
39+
- type: integer
40+
- type: array
41+
items:
42+
type: string

0 commit comments

Comments
 (0)