From ef3f19ebc41aebbd8b8f33f75f7000505be2751b Mon Sep 17 00:00:00 2001 From: Tim Van Wassenhove Date: Thu, 4 Sep 2025 12:11:11 +0200 Subject: [PATCH 1/4] 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. --- .../codegen/languages/RustClientCodegen.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustClientCodegen.java index b1727a6f3d65..fc2aa74e9216 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustClientCodegen.java @@ -307,6 +307,69 @@ public CodegenModel fromModel(String name, Schema model) { mdl.getComposedSchemas().setOneOf(newOneOfs); } + // Handle anyOf schemas similarly to oneOf + // This is pragmatic since Rust's untagged enum will deserialize to the first matching variant + if (mdl.getComposedSchemas() != null && mdl.getComposedSchemas().getAnyOf() != null + && !mdl.getComposedSchemas().getAnyOf().isEmpty()) { + + List newAnyOfs = mdl.getComposedSchemas().getAnyOf().stream() + .map(CodegenProperty::clone) + .collect(Collectors.toList()); + List schemas = ModelUtils.getInterfaces(model); + if (newAnyOfs.size() != schemas.size()) { + // For safety reasons, this should never happen unless there is an error in the code + throw new RuntimeException("anyOf size does not match the model"); + } + + Map refsMapping = Optional.ofNullable(model.getDiscriminator()) + .map(Discriminator::getMapping).orElse(Collections.emptyMap()); + + // Reverse mapped references to use as baseName for anyOf, but different keys may point to the same $ref. + // Thus, we group them by the value + Map> mappedNamesByRef = refsMapping.entrySet().stream() + .collect(Collectors.groupingBy(Map.Entry::getValue, + Collectors.mapping(Map.Entry::getKey, Collectors.toList()) + )); + + for (int i = 0; i < newAnyOfs.size(); i++) { + CodegenProperty anyOf = newAnyOfs.get(i); + Schema schema = schemas.get(i); + + if (mappedNamesByRef.containsKey(schema.get$ref())) { + // prefer mapped names if present + // remove mapping not in order not to reuse for the next occurrence of the ref + List names = mappedNamesByRef.get(schema.get$ref()); + String mappedName = names.remove(0); + anyOf.setBaseName(mappedName); + anyOf.setName(toModelName(mappedName)); + } else if (!org.apache.commons.lang3.StringUtils.isEmpty(schema.get$ref())) { + // use $ref if it's reference + String refName = ModelUtils.getSimpleRef(schema.get$ref()); + if (refName != null) { + String modelName = toModelName(refName); + anyOf.setName(modelName); + anyOf.setBaseName(refName); + } + } else if (anyOf.isArray) { + // If the type is an array, extend the name with the inner type to prevent name collisions + // in case multiple arrays with different types are defined. If the user has manually specified + // a name, use that name instead. + String collectionWithTypeName = toModelName(schema.getType()) + anyOf.containerTypeMapped + anyOf.items.dataType; + String anyOfName = Optional.ofNullable(schema.getTitle()).orElse(collectionWithTypeName); + anyOf.setName(anyOfName); + } + else { + // In-placed type (primitive), because there is no mapping or ref for it. + // use camelized `title` if present, otherwise use `type` + String anyOfName = Optional.ofNullable(schema.getTitle()).orElseGet(schema::getType); + anyOf.setName(toModelName(anyOfName)); + } + } + + // Set anyOf as oneOf for template processing since we want the same output + mdl.getComposedSchemas().setOneOf(newAnyOfs); + } + return mdl; } From 5ae9f755ebcc2228e605a684c1ce97848c0ccd93 Mon Sep 17 00:00:00 2001 From: Tim Van Wassenhove Date: Thu, 4 Sep 2025 12:11:33 +0200 Subject: [PATCH 2/4] 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 --- .../codegen/rust/RustClientCodegenTest.java | 33 +++++++++++++++ .../resources/3_0/rust/rust-anyof-test.yaml | 42 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_0/rust/rust-anyof-test.yaml diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java index c092ad3d7aeb..014b65e6205a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java @@ -271,4 +271,37 @@ public void testMultipleArrayTypesEnum() throws IOException { TestUtils.assertFileExists(outputPath); TestUtils.assertFileContains(outputPath, enumSpec); } + + @Test + public void testAnyOfSupport() throws IOException { + Path target = Files.createTempDirectory("test-anyof"); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust") + .setInputSpec("src/test/resources/3_0/rust/rust-anyof-test.yaml") + .setSkipOverwrite(false) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + // Test that ModelIdentifier generates an untagged enum, not an empty struct + Path modelIdentifierPath = Path.of(target.toString(), "/src/models/model_identifier.rs"); + TestUtils.assertFileExists(modelIdentifierPath); + + // Should generate an untagged enum + String enumDeclaration = linearize("#[serde(untagged)] pub enum ModelIdentifier"); + TestUtils.assertFileContains(modelIdentifierPath, enumDeclaration); + + // Should have String variant (for anyOf with string types) + TestUtils.assertFileContains(modelIdentifierPath, "String(String)"); + + // Should NOT generate an empty struct + TestUtils.assertFileNotContains(modelIdentifierPath, "pub struct ModelIdentifier {"); + TestUtils.assertFileNotContains(modelIdentifierPath, "pub fn new()"); + + // Test AnotherAnyOfTest with mixed types + Path anotherTestPath = Path.of(target.toString(), "/src/models/another_any_of_test.rs"); + TestUtils.assertFileExists(anotherTestPath); + TestUtils.assertFileContains(anotherTestPath, "#[serde(untagged)]"); + TestUtils.assertFileContains(anotherTestPath, "pub enum AnotherAnyOfTest"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/rust/rust-anyof-test.yaml b/modules/openapi-generator/src/test/resources/3_0/rust/rust-anyof-test.yaml new file mode 100644 index 000000000000..2be206184426 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/rust/rust-anyof-test.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: Rust anyOf Test + version: 1.0.0 +paths: + /model: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TestResponse' +components: + schemas: + TestResponse: + type: object + properties: + model: + $ref: '#/components/schemas/ModelIdentifier' + status: + type: string + ModelIdentifier: + description: Model identifier that can be a string or specific enum value + anyOf: + - type: string + description: Any model name as string + - type: string + enum: + - gpt-4 + - gpt-3.5-turbo + - dall-e-3 + description: Known model enum values + AnotherAnyOfTest: + description: Another test case with different types + anyOf: + - type: string + - type: integer + - type: array + items: + type: string \ No newline at end of file From 915bd56b90a5ecd4171723aab60886f9e1dd7ee0 Mon Sep 17 00:00:00 2001 From: Tim Van Wassenhove Date: Fri, 5 Sep 2025 07:46:42 +0200 Subject: [PATCH 3/4] 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. --- .../src/main/resources/rust/model.mustache | 98 +++++++++++++++---- .../org/openapitools/codegen/TestUtils.java | 2 +- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/rust/model.mustache b/modules/openapi-generator/src/main/resources/rust/model.mustache index a4970abf4299..20c76904c949 100644 --- a/modules/openapi-generator/src/main/resources/rust/model.mustache +++ b/modules/openapi-generator/src/main/resources/rust/model.mustache @@ -121,8 +121,39 @@ impl Default for {{classname}} { {{!-- for non-enum schemas --}} {{^isEnum}} {{^discriminator}} +{{#composedSchemas}} +{{#oneOf}} +{{#-first}} +{{! Model with composedSchemas.oneOf - generate enum}} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] pub enum {{classname}} { +{{/-first}} +{{/oneOf}} +{{/composedSchemas}} +{{#composedSchemas}} +{{#oneOf}} + {{#description}} + /// {{{.}}} + {{/description}} + {{{name}}}({{#isModel}}{{^avoidBoxedModels}}Box<{{/avoidBoxedModels}}{{/isModel}}{{{dataType}}}{{#isModel}}{{^avoidBoxedModels}}>{{/avoidBoxedModels}}{{/isModel}}), +{{/oneOf}} +{{/composedSchemas}} +{{#composedSchemas}} +{{#oneOf}} +{{#-last}} +} + +impl Default for {{classname}} { + fn default() -> Self { + {{#oneOf}}{{#-first}}Self::{{{name}}}(Default::default()){{/-first}}{{/oneOf}} + } +} +{{/-last}} +{{/oneOf}} +{{^oneOf}} +{{! composedSchemas exists but no oneOf - generate normal struct}} {{#vendorExtensions.x-rust-has-byte-array}}#[serde_as] -{{/vendorExtensions.x-rust-has-byte-array}}{{#oneOf.isEmpty}}#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +{{/vendorExtensions.x-rust-has-byte-array}}#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct {{{classname}}} { {{#vars}} {{#description}} @@ -172,29 +203,62 @@ impl {{{classname}}} { } } } -{{/oneOf.isEmpty}} -{{^oneOf.isEmpty}} -{{! TODO: add other vars that are not part of the oneOf}} -{{#description}} -/// {{{.}}} -{{/description}} -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum {{classname}} { -{{#composedSchemas.oneOf}} +{{/oneOf}} +{{/composedSchemas}} +{{^composedSchemas}} +{{! Normal struct without composedSchemas}} +{{#vendorExtensions.x-rust-has-byte-array}}#[serde_as] +{{/vendorExtensions.x-rust-has-byte-array}}#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct {{{classname}}} { +{{#vars}} {{#description}} /// {{{.}}} {{/description}} - {{{name}}}({{#isModel}}{{^avoidBoxedModels}}Box<{{/avoidBoxedModels}}{{/isModel}}{{{dataType}}}{{#isModel}}{{^avoidBoxedModels}}>{{/avoidBoxedModels}}{{/isModel}}), -{{/composedSchemas.oneOf}} + {{#isByteArray}} + {{#vendorExtensions.isMandatory}}#[serde_as(as = "serde_with::base64::Base64")]{{/vendorExtensions.isMandatory}}{{^vendorExtensions.isMandatory}}#[serde_as(as = "{{^serdeAsDoubleOption}}Option{{/serdeAsDoubleOption}}{{#serdeAsDoubleOption}}super::DoubleOption{{/serdeAsDoubleOption}}")]{{/vendorExtensions.isMandatory}} + {{/isByteArray}} + #[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}})] + pub {{{name}}}: {{! + ### Option Start + }}{{#isNullable}}Option<{{/isNullable}}{{^required}}Option<{{/required}}{{! + ### Enums + }}{{#isEnum}}{{#isArray}}{{#uniqueItems}}std::collections::HashSet<{{/uniqueItems}}{{^uniqueItems}}Vec<{{/uniqueItems}}{{/isArray}}{{{enumName}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{! + ### Non-Enums Start + }}{{^isEnum}}{{! + ### Models + }}{{#isModel}}{{^avoidBoxedModels}}Box<{{/avoidBoxedModels}}{{{dataType}}}{{^avoidBoxedModels}}>{{/avoidBoxedModels}}{{/isModel}}{{! + ### Primative datatypes + }}{{^isModel}}{{#isByteArray}}Vec{{/isByteArray}}{{^isByteArray}}{{{dataType}}}{{/isByteArray}}{{/isModel}}{{! + ### Non-Enums End + }}{{/isEnum}}{{! + ### Option End (and trailing comma) + }}{{#isNullable}}>{{/isNullable}}{{^required}}>{{/required}}, +{{/vars}} } -impl Default for {{classname}} { - fn default() -> Self { - {{#composedSchemas.oneOf}}{{#-first}}Self::{{{name}}}(Default::default()){{/-first}}{{/composedSchemas.oneOf}} +impl {{{classname}}} { + {{#description}} + /// {{{.}}} + {{/description}} + pub fn new({{#requiredVars}}{{{name}}}: {{! + ### Option Start + }}{{#isNullable}}Option<{{/isNullable}}{{! + ### Enums + }}{{#isEnum}}{{#isArray}}{{#uniqueItems}}std::collections::HashSet<{{/uniqueItems}}{{^uniqueItems}}Vec<{{/uniqueItems}}{{/isArray}}{{{enumName}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{! + ### Non-Enums + }}{{^isEnum}}{{#isByteArray}}Vec{{/isByteArray}}{{^isByteArray}}{{{dataType}}}{{/isByteArray}}{{/isEnum}}{{! + ### Option End + }}{{#isNullable}}>{{/isNullable}}{{! + ### Comma for next arguement + }}{{^-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}}, + {{/vars}} + } } } -{{/oneOf.isEmpty}} +{{/composedSchemas}} {{/discriminator}} {{/isEnum}} {{!-- for properties that are of enum type --}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/TestUtils.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/TestUtils.java index 9f11b2fce037..61f8db3a0c6f 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/TestUtils.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/TestUtils.java @@ -195,7 +195,7 @@ public static int countOccurrences(String content, String text) { } public static String linearize(String target) { - return target.replaceAll("\r?\n", "").replaceAll("\\s+", "\\s"); + return target.replaceAll("\r?\n", "").replaceAll("\\s+", " "); } public static void assertFileNotContains(Path path, String... lines) { From a069df7a2ba72647b40d442f4f16b2da8e1664b9 Mon Sep 17 00:00:00 2001 From: Tim Van Wassenhove Date: Fri, 5 Sep 2025 21:42:40 +0200 Subject: [PATCH 4/4] 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 --- .../openapi-generator/src/main/resources/rust/model.mustache | 3 ++- .../src/test/java/org/openapitools/codegen/TestUtils.java | 2 +- .../org/openapitools/codegen/rust/RustClientCodegenTest.java | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/rust/model.mustache b/modules/openapi-generator/src/main/resources/rust/model.mustache index 20c76904c949..dac0172ea93e 100644 --- a/modules/openapi-generator/src/main/resources/rust/model.mustache +++ b/modules/openapi-generator/src/main/resources/rust/model.mustache @@ -126,7 +126,8 @@ impl Default for {{classname}} { {{#-first}} {{! Model with composedSchemas.oneOf - generate enum}} #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] pub enum {{classname}} { +#[serde(untagged)] +pub enum {{classname}} { {{/-first}} {{/oneOf}} {{/composedSchemas}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/TestUtils.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/TestUtils.java index 61f8db3a0c6f..9f11b2fce037 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/TestUtils.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/TestUtils.java @@ -195,7 +195,7 @@ public static int countOccurrences(String content, String text) { } public static String linearize(String target) { - return target.replaceAll("\r?\n", "").replaceAll("\\s+", " "); + return target.replaceAll("\r?\n", "").replaceAll("\\s+", "\\s"); } public static void assertFileNotContains(Path path, String... lines) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java index 014b65e6205a..05527e99247e 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java @@ -288,8 +288,8 @@ public void testAnyOfSupport() throws IOException { TestUtils.assertFileExists(modelIdentifierPath); // Should generate an untagged enum - String enumDeclaration = linearize("#[serde(untagged)] pub enum ModelIdentifier"); - TestUtils.assertFileContains(modelIdentifierPath, enumDeclaration); + TestUtils.assertFileContains(modelIdentifierPath, "#[serde(untagged)]"); + TestUtils.assertFileContains(modelIdentifierPath, "pub enum ModelIdentifier"); // Should have String variant (for anyOf with string types) TestUtils.assertFileContains(modelIdentifierPath, "String(String)");