Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> schemaKeyToModelNameCache = new HashMap<>();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()));
Expand Down Expand Up @@ -323,6 +335,10 @@ public boolean getUseOneOfDiscriminatorLookup() {
return this.useOneOfDiscriminatorLookup;
}

public boolean getUseAnyOfAllMatches() {
return this.useAnyOfAllMatches;
}

public void setIsGoSubmodule(boolean isGoSubmodule) {
this.isGoSubmodule = isGoSubmodule;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}});
Expand All @@ -65,17 +89,65 @@ 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}})
}

{{/anyOf}}
return nil, nil // no data in anyOf schemas
{{/useAnyOfAllMatches}}
}

{{#discriminator}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<File> 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<File> 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<File> 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<File> 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -60,6 +62,7 @@ public Map<String, String> 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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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