Skip to content
Merged
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
42 changes: 42 additions & 0 deletions flashduty_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,48 @@ func TestNewRequestBuildsPostWithAppKeyAndJSON(t *testing.T) {
}
}

// TestOptionalObjectRequestFieldOmitsWhenUnset guards against a codegen
// regression: encoding/json's `,omitempty` never drops a bare struct value
// (only false/0/""/nil-slice/nil-map/nil-pointer count as "empty"), so an
// unset optional object request field used to always be sent as `{}`. For
// CreateSilenceRuleRequest.TimeFilter this made a recurring-only silence rule
// (TimeFilters set, TimeFilter unset) impossible: the server's binding
// validates StartTime/EndTime with `gt=0` whenever "time_filter" is present
// in the payload at all. The generator now emits `,omitzero` for optional
// struct-typed request fields, which correctly drops the zero value.
func TestOptionalObjectRequestFieldOmitsWhenUnset(t *testing.T) {
c, _ := NewClient("KEY", WithBaseURL("https://api.flashcat.cloud"), WithLogger(noopLogger{}))

req, err := c.newRequest(context.Background(), http.MethodPost, "/silence-rule/create", &CreateSilenceRuleRequest{
RuleName: "recurring only",
TimeFilters: []CreateSilenceRuleRequestTimeFiltersItem{
{Start: "09:00", End: "18:00"},
},
})
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(req.Body)
if strings.Contains(string(body), `"time_filter"`) {
t.Fatalf("unset TimeFilter must be omitted from the wire, got body = %s", body)
}
if !strings.Contains(string(body), `"time_filters"`) {
t.Fatalf("TimeFilters must be present, got body = %s", body)
}

req, err = c.newRequest(context.Background(), http.MethodPost, "/silence-rule/create", &CreateSilenceRuleRequest{
RuleName: "one-off only",
TimeFilter: CreateSilenceRuleRequestTimeFilter{StartTime: 1000, EndTime: 2000},
})
if err != nil {
t.Fatal(err)
}
body, _ = io.ReadAll(req.Body)
if !strings.Contains(string(body), `"time_filter"`) {
t.Fatalf("set TimeFilter must be present on the wire, got body = %s", body)
}
}

func TestNewRequestAppliesHookAndHeaders(t *testing.T) {
c, _ := NewClient("KEY",
WithRequestHeaders(map[string][]string{"X-Static": {"s"}}),
Expand Down
64 changes: 59 additions & 5 deletions internal/cmd/gen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -726,11 +726,28 @@ func (g *Gen) emitStruct(name string, s map[string]any) string {
// (--json/--toon), so omitempty would silently swallow the false/0/[]/{}
// state fields the help advertises as present (e.g. rule.enabled=false on a
// disabled rule). Render them faithfully.
tag := k
jsonTag, toonTag := k, k
if inReq {
tag = k + ",omitempty"
toonTag = k + ",omitempty"
if g.isStructSchema(pv) {
// stdlib encoding/json's `,omitempty` never fires for a bare
// struct value (only false/0/""/nil-slice/nil-map/nil-pointer
// count as "empty"), so an unset optional object field was
// always sent on the wire as `{}` — silently turning an
// absent field into a present-but-empty one and breaking
// backend validation that branches on the field's presence
// (e.g. CreateSilenceRuleRequest.TimeFilter vs TimeFilters).
// `,omitzero` (Go 1.24+) checks the type's actual zero value
// instead and correctly drops it. The `toon` tag keeps
// `,omitempty` unchanged: toon-go's structmeta only
// recognizes that literal option string, so switching it too
// would silently stop omitting these fields from TOON output.
jsonTag = k + ",omitzero"
} else {
jsonTag = k + ",omitempty"
}
}
fmt.Fprintf(&b, "\t%s %s `json:%q toon:%q`\n", field, gt, tag, tag)
fmt.Fprintf(&b, "\t%s %s `json:%q toon:%q`\n", field, gt, jsonTag, toonTag)
}
b.WriteString("}\n\n")
return b.String()
Expand Down Expand Up @@ -975,8 +992,9 @@ func isNullable(s map[string]any) bool {
}

// pointerizableScalar reports whether gt is a scalar Go type that should be
// pointer-wrapped when nullable. Slices/maps/structs are already nil-able and
// carry no omitempty-drops-zero hazard, so they are left as-is.
// pointer-wrapped when nullable. Slices/maps are already nil-able and carry no
// omitempty-drops-zero hazard; structs have their own hazard (see
// isStructSchema) handled via `,omitzero` instead of pointer-wrapping.
func pointerizableScalar(gt string) bool {
switch gt {
case "bool", "int", "int64", "uint64", "float64", "string":
Expand All @@ -986,6 +1004,42 @@ func pointerizableScalar(gt string) bool {
}
}

// isStructSchema reports whether property schema s is emitted as a Go struct
// (as opposed to a scalar, enum, alias, slice, or map). It resolves s the same
// way emitModels' top-level classification switch does (following $ref and
// allOf). encoding/json's `,omitempty` never drops a bare struct value —
// struct is not one of the "empty" kinds it recognizes — so request fields in
// this category need `,omitzero` instead (see emitStruct).
func (g *Gen) isStructSchema(s map[string]any) bool {
if s == nil {
return false
}
if ref := refName(s); ref != "" {
return g.isStructSchema(asMap(g.schemas[ref]))
}
if len(asSlice(s["allOf"])) > 0 {
s = g.mergeAllOf(s)
}
if len(asSlice(s["oneOf"])) > 0 || len(asSlice(s["anyOf"])) > 0 {
return false
}
switch {
case s["enum"] != nil && typeStr(s) == "string":
return false
case typeStr(s) == "array":
return false
case typeStr(s) == "string", typeStr(s) == "integer", typeStr(s) == "number", typeStr(s) == "boolean":
return false
case typeStr(s) == "object", typeStr(s) == "":
if ap, ok := s["additionalProperties"]; ok && ap != nil {
return false // map payload, mirrors goTypeOf's additionalProperties branch
}
return len(asMap(s["properties"])) > 0
default:
return false
}
}

// isIntProp reports whether a property schema is a plain integer (used to detect
// the pagination input trio).
func isIntProp(v any) bool {
Expand Down
Loading