Skip to content

Commit ea8b134

Browse files
committed
fix(go-server): allow zero values for required request body fields (#20496)
Validate required properties by JSON key presence in UnmarshalJSON instead of rejecting primitive zero values via IsZeroValue, aligning with go-client.
1 parent baaf9f9 commit ea8b134

87 files changed

Lines changed: 2467 additions & 159 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/samples-go.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
- samples/server/petstore/go-chi-server/
2828
- samples/server/others/go-server/no-body-path-params/
2929
- samples/server/others/go-server/optional-body/
30+
- samples/server/others/go-server/required-zero-value/
3031
steps:
3132
- uses: actions/checkout@v7
3233
- uses: actions/setup-go@v6

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoServerCodegen.java

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
import java.io.File;
3333
import java.util.*;
34+
import java.util.stream.Collectors;
3435

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

@@ -296,6 +297,88 @@ public void processOpts() {
296297
.doNotOverwrite());
297298
}
298299

300+
@Override
301+
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
302+
objs = super.postProcessAllModels(objs);
303+
304+
for (ModelsMap modelsMap : objs.values()) {
305+
List<Map<String, String>> imports = modelsMap.getImports();
306+
if (imports == null) {
307+
imports = new ArrayList<>();
308+
modelsMap.setImports(imports);
309+
}
310+
for (ModelMap modelMap : modelsMap.getModels()) {
311+
configureModelRequiredValidation(modelMap.getModel(), imports);
312+
}
313+
}
314+
315+
return objs;
316+
}
317+
318+
/**
319+
* Configures UnmarshalJSON presence checks and AssertRequired helpers for request-body models.
320+
* Runs in {@link #postProcessAllModels} so {@code parentModel} is populated for allOf embedding.
321+
*/
322+
private void configureModelRequiredValidation(CodegenModel model, List<Map<String, String>> imports) {
323+
if (model == null || model.isEnum || !model.hasRequired) {
324+
return;
325+
}
326+
327+
Set<String> structFieldBaseNames = model.vars.stream()
328+
.filter(v -> !v.isReadOnly)
329+
.map(v -> v.baseName)
330+
.collect(Collectors.toCollection(LinkedHashSet::new));
331+
332+
boolean hasEmbeddedParent = model.parentModel != null;
333+
if (hasEmbeddedParent) {
334+
model.vendorExtensions.put("x-go-embed-parent-classname", model.parentModel.classname);
335+
for (CodegenProperty v : model.parentModel.vars) {
336+
if (!v.isReadOnly) {
337+
structFieldBaseNames.add(v.baseName);
338+
}
339+
}
340+
}
341+
342+
List<CodegenProperty> presenceCheckRequiredVars = new ArrayList<>();
343+
for (CodegenProperty v : model.vars) {
344+
if (v.required && !v.isReadOnly) {
345+
presenceCheckRequiredVars.add(v);
346+
}
347+
}
348+
if (hasEmbeddedParent) {
349+
for (CodegenProperty v : model.parentModel.vars) {
350+
if (v.required && !v.isReadOnly) {
351+
presenceCheckRequiredVars.add(v);
352+
}
353+
}
354+
}
355+
356+
model.vendorExtensions.put("presenceCheckRequiredVars", presenceCheckRequiredVars);
357+
boolean hasPresenceCheckRequiredVars = !presenceCheckRequiredVars.isEmpty();
358+
model.vendorExtensions.put("hasPresenceCheckRequiredVars", hasPresenceCheckRequiredVars);
359+
360+
if (hasPresenceCheckRequiredVars) {
361+
imports.add(createMapping("import", "bytes"));
362+
imports.add(createMapping("import", "encoding/json"));
363+
}
364+
365+
List<String> allowedJsonKeys = new ArrayList<>(structFieldBaseNames);
366+
model.vendorExtensions.put("allowedJsonKeys", allowedJsonKeys);
367+
model.vendorExtensions.put("x-go-has-parent", hasEmbeddedParent);
368+
369+
boolean hasRequiredAssertVars = model.vars.stream()
370+
.anyMatch(v -> v.required && !v.isReadOnly && !v.isNullable
371+
&& (v.isModel || v.isArray || v.isMap));
372+
model.vendorExtensions.put("hasRequiredAssertVars", hasRequiredAssertVars);
373+
if (hasRequiredAssertVars) {
374+
List<CodegenProperty> assertRequiredVars = model.vars.stream()
375+
.filter(v -> v.required && !v.isReadOnly && !v.isNullable
376+
&& (v.isModel || v.isArray || v.isMap))
377+
.collect(Collectors.toList());
378+
model.vendorExtensions.put("assertRequiredVars", assertRequiredVars);
379+
}
380+
}
381+
299382
@Override
300383
public ModelsMap postProcessModels(ModelsMap objs) {
301384
// The superclass determines the list of required golang imports. The actual list of imports

modules/openapi-generator/src/main/resources/go-server/controller-api.mustache

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ package {{packageName}}
55
import (
66
{{#hasBodyParams}}
77
"encoding/json"
8+
"errors"
89
{{/hasBodyParams}}
910
{{#hasOptionalBodyParams}}
10-
"errors"
1111
"io"
1212
{{/hasOptionalBodyParams}}
1313
"net/http"
@@ -614,6 +614,11 @@ func (c *{{classname}}Controller) {{operationId}}(w http.ResponseWriter, r *http
614614
d.DisallowUnknownFields()
615615
{{/isAdditionalPropertiesTrue}}
616616
if err := d.Decode(&{{paramName}}Param); err != nil {{^required}}&& !errors.Is(err, io.EOF) {{/required}}{
617+
var requiredErr *RequiredError
618+
if errors.As(err, &requiredErr) {
619+
c.errorHandler(w, r, err, nil)
620+
return
621+
}
617622
c.errorHandler(w, r, &ParsingError{Err: err}, nil)
618623
return
619624
}

modules/openapi-generator/src/main/resources/go-server/error.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type RequiredError struct {
3636
}
3737

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

4242
// ErrorHandler defines the required method for handling error. You may implement it and inject this into a controller if

modules/openapi-generator/src/main/resources/go-server/model.mustache

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,48 +57,46 @@ func New{{{classname}}}FromValue(v {{{format}}}{{^format}}{{dataType}}{{/format}
5757
{{/isEnum}}{{^isEnum}}{{#description}}
5858
// {{classname}} - {{{description}}}{{/description}}
5959
type {{classname}} struct {
60-
{{#parent}}
61-
{{^isMap}}
62-
{{^isArray}}
63-
{{{parent}}}
64-
{{/isArray}}
65-
{{/isMap}}
66-
{{#isArray}}
67-
Items {{{parent}}}
68-
{{/isArray}}
69-
{{/parent}}
60+
{{#vendorExtensions.x-go-embed-parent-classname}}
61+
{{{.}}}
62+
{{/vendorExtensions.x-go-embed-parent-classname}}
7063
{{#vars}}{{#description}}
7164
// {{{.}}}{{/description}}
7265
{{#deprecated}}
7366
// Deprecated
7467
{{/deprecated}}
7568
{{name}} {{#isNullable}}*{{/isNullable}}{{{dataType}}} `json:"{{baseName}}{{^required}},omitempty{{/required}}"{{#vendorExtensions.x-go-custom-tag}} {{{.}}}{{/vendorExtensions.x-go-custom-tag}}`
7669
{{/vars}}
77-
}{{/isEnum}}
70+
}
71+
{{#vendorExtensions.hasPresenceCheckRequiredVars}}
72+
{{>model_unmarshal_json_presence}}
73+
{{/vendorExtensions.hasPresenceCheckRequiredVars}}
74+
{{/isEnum}}
7875

79-
// Assert{{classname}}Required checks if the required fields are not zero-ed
76+
// Assert{{classname}}Required checks complex required fields (models, arrays, maps) and embedded parents.
77+
// Primitive required fields are validated for JSON request bodies in UnmarshalJSON so zero values remain valid.
8078
func Assert{{classname}}Required(obj {{classname}}) error {
8179
{{#hasRequired}}
80+
{{#vendorExtensions.hasRequiredAssertVars}}
8281
elements := map[string]interface{}{
83-
{{#requiredVars}}{{^isReadOnly}} "{{baseName}}": obj.{{name}},
84-
{{/isReadOnly}}{{/requiredVars}} }
82+
{{#vendorExtensions.assertRequiredVars}}
83+
"{{baseName}}": obj.{{name}},
84+
{{/vendorExtensions.assertRequiredVars}}
85+
}
8586
for name, el := range elements {
8687
if isZero := IsZeroValue(el); isZero {
8788
return &RequiredError{Field: name}
8889
}
8990
}
9091

92+
{{/vendorExtensions.hasRequiredAssertVars}}
9193
{{/hasRequired}}
92-
{{#parent}}
93-
{{^isMap}}
94-
{{^isArray}}
95-
if err := Assert{{{parent}}}Required(obj.{{{parent}}}); err != nil {
94+
{{#vendorExtensions.x-go-embed-parent-classname}}
95+
if err := Assert{{vendorExtensions.x-go-embed-parent-classname}}Required(obj.{{vendorExtensions.x-go-embed-parent-classname}}); err != nil {
9696
return err
9797
}
9898

99-
{{/isArray}}
100-
{{/isMap}}
101-
{{/parent}}
99+
{{/vendorExtensions.x-go-embed-parent-classname}}
102100
{{#Vars}}
103101
{{^isReadOnly}}
104102
{{#isNullable}}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
type _{{classname}} {{classname}}
2+
3+
// UnmarshalJSON validates required property keys then unmarshals into {{classname}}
4+
func (o *{{classname}}) UnmarshalJSON(data []byte) (err error) {
5+
// Presence is checked against required fields that exist on this struct (own fields
6+
// and embedded allOf parent fields). Only keys that map to struct fields are passed
7+
// to the strict decoder.
8+
requiredProperties := []string{
9+
{{#vendorExtensions.presenceCheckRequiredVars}}
10+
"{{baseName}}",
11+
{{/vendorExtensions.presenceCheckRequiredVars}}
12+
}
13+
14+
requiredNullableProperties := map[string]bool{
15+
{{#vendorExtensions.presenceCheckRequiredVars}}
16+
"{{baseName}}": {{#isNullable}}true{{/isNullable}}{{^isNullable}}false{{/isNullable}},
17+
{{/vendorExtensions.presenceCheckRequiredVars}}
18+
}
19+
20+
{{^vendorExtensions.x-go-has-parent}}
21+
allowedJsonKeys := []string{
22+
{{#vendorExtensions.allowedJsonKeys}}
23+
"{{.}}",
24+
{{/vendorExtensions.allowedJsonKeys}}
25+
}
26+
27+
{{/vendorExtensions.x-go-has-parent}}
28+
allProperties := make(map[string]interface{})
29+
30+
err = json.Unmarshal(data, &allProperties)
31+
32+
if err != nil {
33+
return err
34+
}
35+
36+
for _, requiredProperty := range requiredProperties {
37+
value, exists := allProperties[requiredProperty]
38+
if !exists {
39+
return &RequiredError{Field: requiredProperty}
40+
}
41+
if value == nil && !requiredNullableProperties[requiredProperty] {
42+
return &RequiredError{Field: requiredProperty}
43+
}
44+
}
45+
46+
var filteredData []byte
47+
{{#vendorExtensions.x-go-has-parent}}
48+
// Parent struct fields are embedded; the strict decoder already knows them.
49+
filteredData, err = json.Marshal(allProperties)
50+
{{/vendorExtensions.x-go-has-parent}}
51+
{{^vendorExtensions.x-go-has-parent}}
52+
{{#isAdditionalPropertiesTrue}}
53+
filteredData, err = json.Marshal(allProperties)
54+
{{/isAdditionalPropertiesTrue}}
55+
{{^isAdditionalPropertiesTrue}}
56+
filteredProperties := make(map[string]interface{})
57+
for _, allowedKey := range allowedJsonKeys {
58+
if value, exists := allProperties[allowedKey]; exists {
59+
filteredProperties[allowedKey] = value
60+
}
61+
}
62+
63+
filteredData, err = json.Marshal(filteredProperties)
64+
{{/isAdditionalPropertiesTrue}}
65+
{{/vendorExtensions.x-go-has-parent}}
66+
if err != nil {
67+
return err
68+
}
69+
70+
var{{classname}} := _{{classname}}{}
71+
72+
decoder := json.NewDecoder(bytes.NewReader(filteredData))
73+
{{^isAdditionalPropertiesTrue}}
74+
decoder.DisallowUnknownFields()
75+
{{/isAdditionalPropertiesTrue}}
76+
err = decoder.Decode(&var{{classname}})
77+
78+
if err != nil {
79+
return err
80+
}
81+
82+
*o = {{classname}}(var{{classname}})
83+
84+
return nil
85+
}

0 commit comments

Comments
 (0)