From e371feb1b6a4111a3f8428491f3884e9491dbfc7 Mon Sep 17 00:00:00 2001 From: Arieh Schneier <15041913+AriehSchneier@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:39:57 +1100 Subject: [PATCH 1/4] [go] add useAnyOfAllMatches option for spec-compliant anyOf unmarshalling Add useAnyOfAllMatches option (default false) to try all anyOf schemas and populate every matching field, instead of returning on first match. Default behaviour is unchanged. Includes tests and test YAML spec. --- .../codegen/languages/GoClientCodegen.java | 16 +++++++ .../main/resources/go/model_anyof.mustache | 25 +++++++++++ .../codegen/go/GoClientCodegenTest.java | 44 +++++++++++++++++++ .../codegen/go/GoClientOptionsTest.java | 1 + .../options/GoClientOptionsProvider.java | 3 ++ .../3_0/go/anyof_multiple_matches.yaml | 23 ++++++++++ 6 files changed, 112 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_0/go/anyof_multiple_matches.yaml 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..0572c7d1fab8 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,6 +89,7 @@ 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 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..1144e8c1111c 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,48 @@ 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: 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"); + } + + @Test(description = "anyOf with useAnyOfAllMatches=true: tries all schemas and populates every matching field") + 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"); + // useAnyOfAllMatches=true: all schemas are tried, match count checked + TestUtils.assertFileContains(modelFile, "match++"); + TestUtils.assertFileContains(modelFile, "match >= 1"); + // Should NOT return early on first match + TestUtils.assertFileNotContains(modelFile, "return on the first match"); + } } 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_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' From 600a6e9fa97c018fbd977e844eaed85f53b991b9 Mon Sep 17 00:00:00 2001 From: Arieh Schneier <15041913+AriehSchneier@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:55:01 +1100 Subject: [PATCH 2/4] fix: useAnyOfAllMatches MarshalJSON must merge all non-nil schemas When useAnyOfAllMatches=true, UnmarshalJSON can populate multiple anyOf schema fields simultaneously. The previous MarshalJSON returned on the first non-nil field, which silently discarded all other populated fields, making round-trip serialization lossy and order-dependent. Fix: under useAnyOfAllMatches, MarshalJSON marshals every non-nil schema field to a map[string]interface{} and merges the results before encoding. Non-object schemas (primitives, arrays) that cannot be merged are skipped. The default (first-match) path is unchanged. --- .../main/resources/go/model_anyof.mustache | 30 +++++++++++++++++++ .../codegen/go/GoClientCodegenTest.java | 14 ++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) 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 0572c7d1fab8..8139385a56f6 100644 --- a/modules/openapi-generator/src/main/resources/go/model_anyof.mustache +++ b/modules/openapi-generator/src/main/resources/go/model_anyof.mustache @@ -94,6 +94,35 @@ func (dst *{{classname}}) UnmarshalJSON(data []byte) error { // Marshal data from the first non-nil pointers in the struct to JSON func (src {{classname}}) MarshalJSON() ([]byte, error) { +{{#useAnyOfAllMatches}} + // Merge all non-nil schema fields into a single JSON object so that + // a round-trip through UnmarshalJSON (useAnyOfAllMatches=true) is lossless. + // Non-object schemas (primitives, arrays) that cannot be merged into a map + // are skipped; object schemas have their keys merged with last-write-wins + // semantics on key conflicts. + merged := make(map[string]interface{}) + hasData := false +{{#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 { + merged[k] = v + } + hasData = true + } + } +{{/anyOf}} + if !hasData { + return nil, nil // no data in anyOf schemas + } + return json.Marshal(merged) +{{/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}}) @@ -101,6 +130,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 1144e8c1111c..ffdebee2f5d7 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 @@ -500,13 +500,15 @@ public void testAnyOfUnmarshalDefaultBehaviorReturnOnFirstMatch() throws IOExcep files.forEach(File::deleteOnExit); Path modelFile = Paths.get(output + "/model_contact.go"); - // Default: return on the first match (does not try remaining schemas) + // 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{})"); } - @Test(description = "anyOf with useAnyOfAllMatches=true: tries all schemas and populates every matching field") + @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(); @@ -522,10 +524,14 @@ public void testAnyOfUnmarshalAllMatchesPopulatesAllSchemas() throws IOException files.forEach(File::deleteOnExit); Path modelFile = Paths.get(output + "/model_contact.go"); - // useAnyOfAllMatches=true: all schemas are tried, match count checked + // Unmarshal: all schemas are tried, match count checked TestUtils.assertFileContains(modelFile, "match++"); TestUtils.assertFileContains(modelFile, "match >= 1"); - // Should NOT return early on first match + // Should NOT return early on first unmarshal match TestUtils.assertFileNotContains(modelFile, "return on the first match"); + // Marshal: merge all non-nil schema fields into a single JSON object (lossless round-trip) + TestUtils.assertFileContains(modelFile, "merged := make(map[string]interface{})"); + TestUtils.assertFileContains(modelFile, "merged[k] = v"); + TestUtils.assertFileContains(modelFile, "json.Marshal(merged)"); } } From f35eeb56476a81b463cc03f9533b284ac08522eb Mon Sep 17 00:00:00 2001 From: Arieh Schneier <15041913+AriehSchneier@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:13:19 +1100 Subject: [PATCH 3/4] fix: useAnyOfAllMatches MarshalJSON detect conflicting key values across anyOf schemas When multiple anyOf object schemas share the same field name and are both populated, the merge loop now compares existing and incoming values by their JSON representation. If they differ, MarshalJSON returns an error instead of silently overwriting (last-write-wins), which would produce incorrect output. Identical values for a shared key are allowed (merge succeeds normally). Also adds: - anyof_primitive_schemas.yaml test spec (string | integer anyOf) - anyof_clashing_fields.yaml test spec (two object schemas sharing 'id' with different types) - testAnyOfMarshalConflictingKeysReturnsError in GoClientCodegenTest --- .../main/resources/go/model_anyof.mustache | 37 ++++++++++---- .../codegen/go/GoClientCodegenTest.java | 50 ++++++++++++++++++- .../3_0/go/anyof_clashing_fields.yaml | 28 +++++++++++ .../3_0/go/anyof_primitive_schemas.yaml | 13 +++++ 4 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/go/anyof_clashing_fields.yaml create mode 100644 modules/openapi-generator/src/test/resources/3_0/go/anyof_primitive_schemas.yaml 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 8139385a56f6..23b02dc0a8eb 100644 --- a/modules/openapi-generator/src/main/resources/go/model_anyof.mustache +++ b/modules/openapi-generator/src/main/resources/go/model_anyof.mustache @@ -95,13 +95,17 @@ func (dst *{{classname}}) UnmarshalJSON(data []byte) error { // Marshal data from the first non-nil pointers in the struct to JSON func (src {{classname}}) MarshalJSON() ([]byte, error) { {{#useAnyOfAllMatches}} - // Merge all non-nil schema fields into a single JSON object so that - // a round-trip through UnmarshalJSON (useAnyOfAllMatches=true) is lossless. - // Non-object schemas (primitives, arrays) that cannot be merged into a map - // are skipped; object schemas have their keys merged with last-write-wins - // semantics on key conflicts. + // 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{}) - hasData := false + 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}}) @@ -111,16 +115,29 @@ func (src {{classname}}) MarshalJSON() ([]byte, error) { 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 } - hasData = true + hasObjectData = true + } else if firstNonObjectJSON == nil { + // Not an object (primitive or array); keep as fallback + firstNonObjectJSON = data } } {{/anyOf}} - if !hasData { - return nil, nil // no data in anyOf schemas + if hasObjectData { + return json.Marshal(merged) } - return json.Marshal(merged) + if firstNonObjectJSON != nil { + return firstNonObjectJSON, nil + } + return nil, nil // no data in anyOf schemas {{/useAnyOfAllMatches}} {{^useAnyOfAllMatches}} {{#anyOf}} 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 ffdebee2f5d7..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 @@ -506,6 +506,7 @@ public void testAnyOfUnmarshalDefaultBehaviorReturnOnFirstMatch() throws IOExcep 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") @@ -529,9 +530,56 @@ public void testAnyOfUnmarshalAllMatchesPopulatesAllSchemas() throws IOException 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 schema fields into a single JSON object (lossless round-trip) + // 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/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_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 From e0a11c7902c57359d074c4256fa802ad99603273 Mon Sep 17 00:00:00 2001 From: Arieh Schneier <15041913+AriehSchneier@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:22:39 +1100 Subject: [PATCH 4/4] ci: retry CI (previous run failed due to connection issues)