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; } diff --git a/modules/openapi-generator/src/main/resources/rust/model.mustache b/modules/openapi-generator/src/main/resources/rust/model.mustache index a4970abf4299..dac0172ea93e 100644 --- a/modules/openapi-generator/src/main/resources/rust/model.mustache +++ b/modules/openapi-generator/src/main/resources/rust/model.mustache @@ -121,8 +121,40 @@ 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 +204,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/rust/RustClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java index c092ad3d7aeb..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 @@ -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 + 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)"); + + // 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