diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java index a35aa26bbd50..0ba94d709edd 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java @@ -58,9 +58,11 @@ public class GoClientCodegen extends AbstractGoCodegen { public static final String WITH_GO_MOD = "withGoMod"; public static final String USE_DEFAULT_VALUES_FOR_REQUIRED_VARS = "useDefaultValuesForRequiredVars"; public static final String IMPORT_VALIDATOR = "importValidator"; + public static final String USE_ANYOF_ALL_MATCHES = "useAnyOfAllMatches"; @Setter protected String goImportAlias = "openapiclient"; protected boolean isGoSubmodule = false; @Setter protected boolean useOneOfDiscriminatorLookup = false; // use oneOf discriminator's mapping for model lookup + @Setter protected boolean useAnyOfAllMatches = false; // try all anyOf schemas and populate all matching fields (spec-compliant) // A cache to efficiently lookup schema `toModelName()` based on the schema Key private Map schemaKeyToModelNameCache = new HashMap<>(); @@ -138,6 +140,9 @@ public GoClientCodegen() { .defaultValue(Boolean.FALSE.toString())); cliOptions.add(new CliOption(CodegenConstants.USE_ONEOF_DISCRIMINATOR_LOOKUP, CodegenConstants.USE_ONEOF_DISCRIMINATOR_LOOKUP_DESC).defaultValue("false")); + cliOptions.add(CliOption.newBoolean(USE_ANYOF_ALL_MATCHES, + "When set to true, anyOf UnmarshalJSON tries all schemas and populates all matching fields (spec-compliant: anyOf requires one or more matches). " + + "Default (false) returns on the first successful match, which is faster but non-compliant when multiple schemas can simultaneously match.").defaultValue("false")); // option to change how we process + set the data in the 'additionalProperties' keyword. CliOption disallowAdditionalPropertiesIfNotPresentOpt = CliOption.newBoolean( CodegenConstants.DISALLOW_ADDITIONAL_PROPERTIES_IF_NOT_PRESENT, @@ -275,6 +280,13 @@ public void processOpts() { additionalProperties.put(CodegenConstants.USE_ONEOF_DISCRIMINATOR_LOOKUP, getUseOneOfDiscriminatorLookup()); } + if (additionalProperties.containsKey(USE_ANYOF_ALL_MATCHES)) { + setUseAnyOfAllMatches(Boolean.parseBoolean(additionalProperties.get(USE_ANYOF_ALL_MATCHES).toString())); + additionalProperties.put(USE_ANYOF_ALL_MATCHES, useAnyOfAllMatches); + } else { + additionalProperties.put(USE_ANYOF_ALL_MATCHES, useAnyOfAllMatches); + } + if (additionalProperties.containsKey(CodegenConstants.DISALLOW_ADDITIONAL_PROPERTIES_IF_NOT_PRESENT)) { this.setDisallowAdditionalPropertiesIfNotPresent(Boolean.parseBoolean(additionalProperties .get(CodegenConstants.DISALLOW_ADDITIONAL_PROPERTIES_IF_NOT_PRESENT).toString())); @@ -323,6 +335,10 @@ public boolean getUseOneOfDiscriminatorLookup() { return this.useOneOfDiscriminatorLookup; } + public boolean getUseAnyOfAllMatches() { + return this.useAnyOfAllMatches; + } + public void setIsGoSubmodule(boolean isGoSubmodule) { this.isGoSubmodule = isGoSubmodule; } diff --git a/modules/openapi-generator/src/main/resources/go/model_anyof.mustache b/modules/openapi-generator/src/main/resources/go/model_anyof.mustache index fa0e8834f6e7..23b02dc0a8eb 100644 --- a/modules/openapi-generator/src/main/resources/go/model_anyof.mustache +++ b/modules/openapi-generator/src/main/resources/go/model_anyof.mustache @@ -49,6 +49,30 @@ func (dst *{{classname}}) UnmarshalJSON(data []byte) error { {{/mappedModels}} {{/discriminator}} + {{#useAnyOfAllMatches}} + match := 0 + {{#anyOf}} + // try to unmarshal data into {{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}} + err = json.Unmarshal(data, &dst.{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}}) + if err == nil { + json{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}}, _ := json.Marshal(dst.{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}}) + if string(json{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}}) == "{}" { // empty struct + dst.{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}} = nil + } else { + match++ // populated at least one field + } + } else { + dst.{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}} = nil + } + + {{/anyOf}} + if match >= 1 { // at least 1 match as required by anyOf + return nil + } else { // no match + return fmt.Errorf("data failed to match schemas in anyOf({{classname}})") + } + {{/useAnyOfAllMatches}} + {{^useAnyOfAllMatches}} {{#anyOf}} // try to unmarshal JSON data into {{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}} err = json.Unmarshal(data, &dst.{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}}); @@ -65,10 +89,57 @@ func (dst *{{classname}}) UnmarshalJSON(data []byte) error { {{/anyOf}} return fmt.Errorf("data failed to match schemas in anyOf({{classname}})") + {{/useAnyOfAllMatches}} } // Marshal data from the first non-nil pointers in the struct to JSON func (src {{classname}}) MarshalJSON() ([]byte, error) { +{{#useAnyOfAllMatches}} + // When useAnyOfAllMatches=true, multiple fields may be populated simultaneously. + // Strategy: + // - Object schemas: marshal each to map[string]interface{} and merge (last-write-wins on key conflicts). + // - Non-object schemas (primitives, arrays): cannot be merged into a map; store the first + // one encountered as a fallback. + // If any object schemas contributed data, return the merged object (all objects merged losslessly + // for non-overlapping keys). Otherwise fall back to the first non-object value, preserving the + // data rather than returning nil. + merged := make(map[string]interface{}) + hasObjectData := false + var firstNonObjectJSON []byte +{{#anyOf}} + if src.{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}} != nil { + data, err := json.Marshal(src.{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}}) + if err != nil { + return nil, err + } + var m map[string]interface{} + if jsonErr := json.Unmarshal(data, &m); jsonErr == nil { + for k, v := range m { + if existing, ok := merged[k]; ok { + existingJSON, _ := json.Marshal(existing) + newJSON, _ := json.Marshal(v) + if string(existingJSON) != string(newJSON) { + return nil, fmt.Errorf("anyOf schemas have conflicting values for key %q", k) + } + } + merged[k] = v + } + hasObjectData = true + } else if firstNonObjectJSON == nil { + // Not an object (primitive or array); keep as fallback + firstNonObjectJSON = data + } + } +{{/anyOf}} + if hasObjectData { + return json.Marshal(merged) + } + if firstNonObjectJSON != nil { + return firstNonObjectJSON, nil + } + return nil, nil // no data in anyOf schemas +{{/useAnyOfAllMatches}} +{{^useAnyOfAllMatches}} {{#anyOf}} if src.{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}} != nil { return json.Marshal(&src.{{#lambda.type-to-name}}{{{.}}}{{/lambda.type-to-name}}) @@ -76,6 +147,7 @@ func (src {{classname}}) MarshalJSON() ([]byte, error) { {{/anyOf}} return nil, nil // no data in anyOf schemas +{{/useAnyOfAllMatches}} } {{#discriminator}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/go/GoClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/go/GoClientCodegenTest.java index 329506254579..5ac6c77c4be3 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/go/GoClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/go/GoClientCodegenTest.java @@ -484,4 +484,102 @@ public void testEscapingInExamples() throws IOException { // Verify that quotes are properly escaped in email parameter examples TestUtils.assertFileContains(docPath, "emailWithQuotes := \"test\\\"user@example.com\""); } + + @Test(description = "anyOf default behaviour: return on the first successful schema match") + public void testAnyOfUnmarshalDefaultBehaviorReturnOnFirstMatch() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("go") + .setInputSpec("src/test/resources/3_0/go/anyof_multiple_matches.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path modelFile = Paths.get(output + "/model_contact.go"); + // Default unmarshal: return on the first match (does not try remaining schemas) + TestUtils.assertFileContains(modelFile, "return on the first match"); + TestUtils.assertFileNotContains(modelFile, "match++"); + TestUtils.assertFileNotContains(modelFile, "match >= 1"); + // Default marshal: return on the first non-nil field + TestUtils.assertFileNotContains(modelFile, "merged := make(map[string]interface{})"); + TestUtils.assertFileNotContains(modelFile, "firstNonObjectJSON"); + } + + @Test(description = "anyOf with useAnyOfAllMatches=true: tries all schemas, populates all matching fields, and merges them on re-serialization") + public void testAnyOfUnmarshalAllMatchesPopulatesAllSchemas() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("go") + .addAdditionalProperty(GoClientCodegen.USE_ANYOF_ALL_MATCHES, true) + .setInputSpec("src/test/resources/3_0/go/anyof_multiple_matches.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path modelFile = Paths.get(output + "/model_contact.go"); + // Unmarshal: all schemas are tried, match count checked + TestUtils.assertFileContains(modelFile, "match++"); + TestUtils.assertFileContains(modelFile, "match >= 1"); + // Should NOT return early on first unmarshal match + TestUtils.assertFileNotContains(modelFile, "return on the first match"); + // Marshal: merge all non-nil object schema fields; fall back to first non-object if no objects + TestUtils.assertFileContains(modelFile, "merged := make(map[string]interface{})"); + TestUtils.assertFileContains(modelFile, "merged[k] = v"); + TestUtils.assertFileContains(modelFile, "json.Marshal(merged)"); + TestUtils.assertFileContains(modelFile, "firstNonObjectJSON"); + TestUtils.assertFileContains(modelFile, "return firstNonObjectJSON, nil"); + } + + @Test(description = "anyOf with useAnyOfAllMatches=true and primitive schemas: MarshalJSON must not return nil for non-object variants") + public void testAnyOfAllMatchesPrimitiveFallbackNotNil() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("go") + .addAdditionalProperty(GoClientCodegen.USE_ANYOF_ALL_MATCHES, true) + .setInputSpec("src/test/resources/3_0/go/anyof_primitive_schemas.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path modelFile = Paths.get(output + "/model_string_or_integer.go"); + // Primitive anyOf: no object fields, so the fallback path must be present + TestUtils.assertFileContains(modelFile, "firstNonObjectJSON"); + TestUtils.assertFileContains(modelFile, "return firstNonObjectJSON, nil"); + // The nil guard — ensures we don't return nil when a primitive was matched + TestUtils.assertFileContains(modelFile, "if firstNonObjectJSON != nil"); + } + + @Test + public void testAnyOfMarshalConflictingKeysReturnsError() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("go") + .addAdditionalProperty(GoClientCodegen.USE_ANYOF_ALL_MATCHES, true) + .setInputSpec("src/test/resources/3_0/go/anyof_clashing_fields.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path modelFile = Paths.get(output + "/model_contact.go"); + // Conflict detection: if two object schemas produce different values for the same key, + // MarshalJSON should return an error rather than silently overwriting. + TestUtils.assertFileContains(modelFile, "conflicting values for key"); + TestUtils.assertFileContains(modelFile, "return nil, fmt.Errorf"); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/go/GoClientOptionsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/go/GoClientOptionsTest.java index 9f99b6c4afe8..45944d46986e 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/go/GoClientOptionsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/go/GoClientOptionsTest.java @@ -51,6 +51,7 @@ protected void verifyOptions() { verify(clientCodegen).setStructPrefix(GoClientOptionsProvider.STRUCT_PREFIX_VALUE); verify(clientCodegen).setWithAWSV4Signature(GoClientOptionsProvider.WITH_AWSV4_SIGNATURE); verify(clientCodegen).setUseOneOfDiscriminatorLookup(GoClientOptionsProvider.USE_ONE_OF_DISCRIMINATOR_LOOKUP_VALUE); + verify(clientCodegen).setUseAnyOfAllMatches(GoClientOptionsProvider.USE_ANYOF_ALL_MATCHES_VALUE); verify(clientCodegen).setWithGoMod(GoClientOptionsProvider.WITH_GO_MOD_VALUE); verify(clientCodegen).setGenerateMarshalJSON(GoClientOptionsProvider.GENERATE_MARSHAL_JSON_VALUE); verify(clientCodegen).setGenerateUnmarshalJSON(GoClientOptionsProvider.GENERATE_UNMARSHAL_JSON_VALUE); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/options/GoClientOptionsProvider.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/options/GoClientOptionsProvider.java index 54e2348b3f4d..133e7df5e664 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/options/GoClientOptionsProvider.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/options/GoClientOptionsProvider.java @@ -19,6 +19,7 @@ import com.google.common.collect.ImmutableMap; import org.openapitools.codegen.CodegenConstants; +import org.openapitools.codegen.languages.GoClientCodegen; import java.util.Map; @@ -36,6 +37,7 @@ public class GoClientOptionsProvider implements OptionsProvider { public static final boolean GENERATE_INTERFACES_VALUE = true; public static final boolean DISALLOW_ADDITIONAL_PROPERTIES_IF_NOT_PRESENT_VALUE = true; public static final boolean USE_ONE_OF_DISCRIMINATOR_LOOKUP_VALUE = true; + public static final boolean USE_ANYOF_ALL_MATCHES_VALUE = true; public static final boolean WITH_GO_MOD_VALUE = true; public static final boolean GENERATE_MARSHAL_JSON_VALUE = true; public static final boolean GENERATE_UNMARSHAL_JSON_VALUE = true; @@ -60,6 +62,7 @@ public Map createOptions() { .put(CodegenConstants.WITH_AWSV4_SIGNATURE_COMMENT, "true") .put(CodegenConstants.DISALLOW_ADDITIONAL_PROPERTIES_IF_NOT_PRESENT, "true") .put(CodegenConstants.USE_ONEOF_DISCRIMINATOR_LOOKUP, "true") + .put(GoClientCodegen.USE_ANYOF_ALL_MATCHES, "true") .put(CodegenConstants.WITH_GO_MOD, "true") .put(CodegenConstants.GENERATE_MARSHAL_JSON, "true") .put(CodegenConstants.GENERATE_UNMARSHAL_JSON, "true") diff --git a/modules/openapi-generator/src/test/resources/3_0/go/anyof_clashing_fields.yaml b/modules/openapi-generator/src/test/resources/3_0/go/anyof_clashing_fields.yaml new file mode 100644 index 000000000000..20cc24816f9f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/go/anyof_clashing_fields.yaml @@ -0,0 +1,28 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: anyOf clashing fields test +paths: {} +components: + schemas: + SpecX: + type: object + properties: + id: + type: integer + name: + type: string + SpecY: + type: object + properties: + id: + type: string + value: + type: number + # Contact uses anyOf with two schemas that share the "id" field but with different types. + # When useAnyOfAllMatches=true and both match, MarshalJSON should detect the conflict + # and return an error rather than silently overwriting the "id" key. + Contact: + anyOf: + - $ref: '#/components/schemas/SpecX' + - $ref: '#/components/schemas/SpecY' diff --git a/modules/openapi-generator/src/test/resources/3_0/go/anyof_multiple_matches.yaml b/modules/openapi-generator/src/test/resources/3_0/go/anyof_multiple_matches.yaml new file mode 100644 index 000000000000..5baf501d1a48 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/go/anyof_multiple_matches.yaml @@ -0,0 +1,23 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: anyOf multiple matches test +paths: {} +components: + schemas: + SpecA: + type: object + properties: + name: + type: string + SpecB: + type: object + properties: + value: + type: integer + # Contact uses anyOf with two schemas that can BOTH match simultaneously: + # a payload like {"name":"test","value":42} satisfies both SpecA and SpecB. + Contact: + anyOf: + - $ref: '#/components/schemas/SpecA' + - $ref: '#/components/schemas/SpecB' diff --git a/modules/openapi-generator/src/test/resources/3_0/go/anyof_primitive_schemas.yaml b/modules/openapi-generator/src/test/resources/3_0/go/anyof_primitive_schemas.yaml new file mode 100644 index 000000000000..37fc30e28a19 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/go/anyof_primitive_schemas.yaml @@ -0,0 +1,13 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: anyOf primitive schemas test +paths: {} +components: + schemas: + # A schema whose anyOf variants are primitives (string or integer). + # When useAnyOfAllMatches=true, MarshalJSON must NOT return nil for these. + StringOrInteger: + anyOf: + - type: string + - type: integer