Skip to content
Draft
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
1 change: 1 addition & 0 deletions .github/workflows/samples-go.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
- samples/server/petstore/go-chi-server/
- samples/server/others/go-server/no-body-path-params/
- samples/server/others/go-server/optional-body/
- samples/server/others/go-server/required-zero-value/
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import java.io.File;
import java.util.*;
import java.util.stream.Collectors;

import static org.openapitools.codegen.utils.StringUtils.camelize;

Expand Down Expand Up @@ -314,6 +315,60 @@ public ModelsMap postProcessModels(ModelsMap objs) {
continue;
}

if (model.hasRequired) {
Set<String> ownVarBaseNames = model.vars.stream()
.map(v -> v.baseName)
.collect(Collectors.toSet());

// Own required fields plus inherited required fields from allOf parents that
// are not flattened into this struct. Inherited keys are presence-checked
// against the raw JSON map, then stripped before strict decode.
List<CodegenProperty> presenceCheckRequiredVars = new ArrayList<>();
for (CodegenProperty v : model.vars) {
if (v.required && !v.isReadOnly) {
presenceCheckRequiredVars.add(v);
}
}
for (CodegenProperty v : model.requiredVars) {
if (v.required && !v.isReadOnly && !ownVarBaseNames.contains(v.baseName)) {
presenceCheckRequiredVars.add(v);
}
}
model.vendorExtensions.put("presenceCheckRequiredVars", presenceCheckRequiredVars);
boolean hasPresenceCheckRequiredVars = !presenceCheckRequiredVars.isEmpty();
model.vendorExtensions.put("hasPresenceCheckRequiredVars", hasPresenceCheckRequiredVars);

if (hasPresenceCheckRequiredVars) {
imports.add(createMapping("import", "bytes"));
imports.add(createMapping("import", "encoding/json"));
}

List<String> allowedJsonKeys = new ArrayList<>();
for (CodegenProperty v : model.vars) {
if (!v.isReadOnly) {
allowedJsonKeys.add(v.baseName);
}
}
model.vendorExtensions.put("allowedJsonKeys", allowedJsonKeys);
// Models with embedded parent structs (allOf) have valid JSON keys
// from the parent that the strict decoder already knows about via
// embedding, so we skip the key-filtering step and let the decoder
// reject truly unknown fields.
model.vendorExtensions.put("x-go-has-parent", model.parent != null);

boolean hasRequiredAssertVars = model.vars.stream()
.anyMatch(v -> v.required && !v.isReadOnly && !v.isNullable
&& (v.isModel || v.isArray || v.isMap));
model.vendorExtensions.put("hasRequiredAssertVars", hasRequiredAssertVars);
if (hasRequiredAssertVars) {
List<CodegenProperty> assertRequiredVars = model.vars.stream()
.filter(v -> v.required && !v.isReadOnly && !v.isNullable
&& (v.isModel || v.isArray || v.isMap))
.collect(Collectors.toList());
model.vendorExtensions.put("assertRequiredVars", assertRequiredVars);
}
}

Boolean importErrors = false;

for (CodegenProperty param : Iterables.concat(model.vars, model.allVars, model.requiredVars, model.optionalVars)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ package {{packageName}}
import (
{{#hasBodyParams}}
"encoding/json"
"errors"
{{/hasBodyParams}}
{{#hasOptionalBodyParams}}
"errors"
"io"
{{/hasOptionalBodyParams}}
"net/http"
Expand Down Expand Up @@ -614,6 +614,11 @@ func (c *{{classname}}Controller) {{operationId}}(w http.ResponseWriter, r *http
d.DisallowUnknownFields()
{{/isAdditionalPropertiesTrue}}
if err := d.Decode(&{{paramName}}Param); err != nil {{^required}}&& !errors.Is(err, io.EOF) {{/required}}{
var requiredErr *RequiredError
if errors.As(err, &requiredErr) {
c.errorHandler(w, r, err, nil)
return
}
c.errorHandler(w, r, &ParsingError{Err: err}, nil)
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type RequiredError struct {
}

func (e *RequiredError) Error() string {
return fmt.Sprintf("required field '%s' is zero value.", e.Field)
return fmt.Sprintf("required field '%s' is missing.", e.Field)
}

// ErrorHandler defines the required method for handling error. You may implement it and inject this into a controller if
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,29 @@ type {{classname}} struct {
{{/deprecated}}
{{name}} {{#isNullable}}*{{/isNullable}}{{{dataType}}} `json:"{{baseName}}{{^required}},omitempty{{/required}}"{{#vendorExtensions.x-go-custom-tag}} {{{.}}}{{/vendorExtensions.x-go-custom-tag}}`
{{/vars}}
}{{/isEnum}}
}
{{#vendorExtensions.hasPresenceCheckRequiredVars}}
{{>model_unmarshal_json_presence}}
{{/vendorExtensions.hasPresenceCheckRequiredVars}}
{{/isEnum}}

// Assert{{classname}}Required checks if the required fields are not zero-ed
// Assert{{classname}}Required checks complex required fields (models, arrays, maps) and embedded parents.
// Primitive required fields are validated for JSON request bodies in UnmarshalJSON so zero values remain valid.
func Assert{{classname}}Required(obj {{classname}}) error {
{{#hasRequired}}
{{#vendorExtensions.hasRequiredAssertVars}}
elements := map[string]interface{}{
{{#requiredVars}}{{^isReadOnly}} "{{baseName}}": obj.{{name}},
{{/isReadOnly}}{{/requiredVars}} }
{{#vendorExtensions.assertRequiredVars}}
"{{baseName}}": obj.{{name}},
{{/vendorExtensions.assertRequiredVars}}
}
for name, el := range elements {
if isZero := IsZeroValue(el); isZero {
return &RequiredError{Field: name}
}
}

{{/vendorExtensions.hasRequiredAssertVars}}
{{/hasRequired}}
{{#parent}}
{{^isMap}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
type _{{classname}} {{classname}}

// UnmarshalJSON validates required property keys then unmarshals into {{classname}}
func (o *{{classname}}) UnmarshalJSON(data []byte) (err error) {
// Presence is checked against own required fields and any inherited required
// fields from allOf parents that are not flattened into this struct. Only keys
// that map to struct fields are passed to the strict decoder.
requiredProperties := []string{
{{#vendorExtensions.presenceCheckRequiredVars}}
"{{baseName}}",
{{/vendorExtensions.presenceCheckRequiredVars}}
}

requiredNullableProperties := map[string]bool{
{{#vendorExtensions.presenceCheckRequiredVars}}
"{{baseName}}": {{#isNullable}}true{{/isNullable}}{{^isNullable}}false{{/isNullable}},
{{/vendorExtensions.presenceCheckRequiredVars}}
}

{{^vendorExtensions.x-go-has-parent}}
allowedJsonKeys := []string{
{{#vendorExtensions.allowedJsonKeys}}
"{{.}}",
{{/vendorExtensions.allowedJsonKeys}}
}

{{/vendorExtensions.x-go-has-parent}}
allProperties := make(map[string]interface{})

err = json.Unmarshal(data, &allProperties)

if err != nil {
return err
}

for _, requiredProperty := range requiredProperties {
value, exists := allProperties[requiredProperty]
if !exists {
return &RequiredError{Field: requiredProperty}
}
if value == nil && !requiredNullableProperties[requiredProperty] {
return &RequiredError{Field: requiredProperty}
}
}

var filteredData []byte
{{#vendorExtensions.x-go-has-parent}}
// Parent struct fields are embedded; the strict decoder already knows them.
filteredData, err = json.Marshal(allProperties)
{{/vendorExtensions.x-go-has-parent}}
{{^vendorExtensions.x-go-has-parent}}
{{#isAdditionalPropertiesTrue}}
filteredData, err = json.Marshal(allProperties)
{{/isAdditionalPropertiesTrue}}
{{^isAdditionalPropertiesTrue}}
filteredProperties := make(map[string]interface{})
for _, allowedKey := range allowedJsonKeys {
if value, exists := allProperties[allowedKey]; exists {
filteredProperties[allowedKey] = value
}
}

filteredData, err = json.Marshal(filteredProperties)
{{/isAdditionalPropertiesTrue}}
{{/vendorExtensions.x-go-has-parent}}
if err != nil {
return err
}

var{{classname}} := _{{classname}}{}

decoder := json.NewDecoder(bytes.NewReader(filteredData))
{{^isAdditionalPropertiesTrue}}
decoder.DisallowUnknownFields()
{{/isAdditionalPropertiesTrue}}
err = decoder.Decode(&var{{classname}})

if err != nil {
return err
}

*o = {{classname}}(var{{classname}})

return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,82 @@ public void verifyDuplicateOperationIdsAreConsistent() throws IOException {
"nickname diverged from operationId (FooGet_1 present in api_default_service.go)");
}

@Test
public void verifyRequiredZeroValueAllowed() throws IOException {
File output = Files.createTempDirectory("test").toFile();
output.deleteOnExit();

final CodegenConfigurator configurator = createDefaultCodegenConfigurator(output)
.setInputSpec("src/test/resources/3_0/go-server/required-zero-value.yaml");

DefaultGenerator generator = new DefaultGenerator();
List<File> files = generator.opts(configurator.toClientOptInput()).generate();
files.forEach(File::deleteOnExit);

TestUtils.assertFileExists(Paths.get(output + "/go/model_test_object.go"));
TestUtils.assertFileContains(Paths.get(output + "/go/model_test_object.go"),
"func (o *TestObject) UnmarshalJSON(data []byte) (err error) {");
TestUtils.assertFileContains(Paths.get(output + "/go/model_test_object.go"),
"type _TestObject TestObject");
TestUtils.assertFileContains(Paths.get(output + "/go/model_test_object.go"),
"requiredProperties := []string{");
TestUtils.assertFileContains(Paths.get(output + "/go/model_test_object.go"),
"decoder.DisallowUnknownFields()");
TestUtils.assertFileContains(Paths.get(output + "/go/model_test_object.go"),
"return &RequiredError{Field: requiredProperty}");
TestUtils.assertFileContains(Paths.get(output + "/go/model_test_object.go"),
"if value == nil && !requiredNullableProperties[requiredProperty] {");
TestUtils.assertFileNotContains(Paths.get(output + "/go/model_test_object.go"),
"IsZeroValue");

TestUtils.assertFileExists(Paths.get(output + "/go/api_default.go"));
TestUtils.assertFileContains(Paths.get(output + "/go/api_default.go"),
"var requiredErr *RequiredError");
}

@Test
public void verifyOneOfInlineRequiredZeroValueAllowed() throws IOException {
File output = Files.createTempDirectory("test").toFile();
output.deleteOnExit();

final CodegenConfigurator configurator = createDefaultCodegenConfigurator(output)
.setInputSpec("src/test/resources/3_0/go-server/oneof-inline-required.yaml");

DefaultGenerator generator = new DefaultGenerator();
List<File> files = generator.opts(configurator.toClientOptInput()).generate();
files.forEach(File::deleteOnExit);

java.nio.file.Path modelPath = Paths.get(output + "/go/model_item_request.go");
TestUtils.assertFileExists(modelPath);
TestUtils.assertFileContains(modelPath,
"func (o *ItemRequest) UnmarshalJSON(data []byte) (err error) {");
TestUtils.assertFileContains(modelPath,
"\"quantity\",");
TestUtils.assertFileNotContains(modelPath,
"\"quantity\": obj.Quantity,");
}

@Test
public void verifyOrderUnmarshalJSONHandlesInheritedAllOfRequiredFields() throws IOException {
File output = Files.createTempDirectory("test").toFile();
output.deleteOnExit();

final CodegenConfigurator configurator = createDefaultCodegenConfigurator(output)
.setInputSpec("src/test/resources/3_0/go-server/petstore_with_test_endpoint.yaml")
.addAdditionalProperty("router", "chi");

DefaultGenerator generator = new DefaultGenerator();
List<File> files = generator.opts(configurator.toClientOptInput()).generate();
files.forEach(File::deleteOnExit);

java.nio.file.Path modelPath = Paths.get(output + "/go/model_order.go");
TestUtils.assertFileExists(modelPath);
TestUtils.assertFileContains(modelPath, "\"comment\",");
TestUtils.assertFileContains(modelPath, "\"requireTest\",");
// Parent structs skip key filtering; they marshal allProperties directly.
TestUtils.assertFileContains(modelPath, "filteredData, err = json.Marshal(allProperties)");
}

private static CodegenConfigurator createDefaultCodegenConfigurator(File output) {
return new CodegenConfigurator()
.setGeneratorName("go-server")
Expand All @@ -149,13 +225,16 @@ public void verifyReadOnlyRequiredFieldsNotEnforced() throws IOException {
files.forEach(File::deleteOnExit);

// AssertThingRequired must not reference the readOnly "id" field, so a
// request body that legitimately omits it is accepted. The non-readOnly
// required "name" field must still be enforced.
// request body that legitimately omits it is accepted.
// Primitive non-readOnly required fields are validated in UnmarshalJSON, not here.
java.nio.file.Path modelPath = Paths.get(output + "/go/model_thing.go");
TestUtils.assertFileExists(modelPath);
TestUtils.assertFileContains(modelPath,
"func AssertThingRequired(obj Thing) error {");
TestUtils.assertFileContains(modelPath,
"Primitive required fields are validated for JSON request bodies in UnmarshalJSON");
// name is a primitive string, validated by UnmarshalJSON — excluded from AssertRequired
TestUtils.assertFileNotContains(modelPath,
"\"name\": obj.Name,");
// readOnly "id" must be skipped
TestUtils.assertFileNotContains(modelPath,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
openapi: 3.0.3
info:
title: oneOf inline required zero value
version: 1.0.0
servers:
- url: https://localhost:8080/api/v1
paths:
/items:
post:
summary: Create item
operationId: createItem
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ItemRequest'
responses:
'204':
description: Successful operation
components:
schemas:
NestedLabel:
type: object
properties:
name:
type: string
required:
- name
ItemRequest:
oneOf:
- $ref: '#/components/schemas/NestedLabel'
- type: string
properties:
quantity:
type: integer
minimum: 0
required:
- quantity
Loading
Loading