diff --git a/flashduty_test.go b/flashduty_test.go index 828992b..6a01336 100644 --- a/flashduty_test.go +++ b/flashduty_test.go @@ -97,6 +97,38 @@ func TestOptionalObjectRequestFieldOmitsWhenUnset(t *testing.T) { } } +// TestDashboardVariableOmitsUnsetVariableQuery guards the flattened-oneOf case +// of the rule above: DashboardVariable is one struct for the datasource, +// custom, and query arms, and only the query arm carries variable_query. The +// backend rejects unknown fields per arm, so a datasource variable sent with +// `"variable_query":{"kind":""}` fails the whole dashboard create, update, or +// panel preview. +func TestDashboardVariableOmitsUnsetVariableQuery(t *testing.T) { + body, err := json.Marshal(DashboardVariable{ + Kind: "datasource", + Name: "metrics_ds", + DatasourceType: "prometheus", + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), `"variable_query"`) { + t.Fatalf("unset VariableQuery must be omitted from the wire, got %s", body) + } + + body, err = json.Marshal(DashboardVariable{ + Kind: "query", + Name: "service", + VariableQuery: DashboardVariableQuery{Kind: "prometheus", Metric: "up", Label: "job"}, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"variable_query":{`) { + t.Fatalf("set VariableQuery must be present on the wire, got %s", body) + } +} + // TestResetPostMortemContentSendsZeroExpectedRevision guards a codegen // contract: expected_revision is a required field where 0 is a valid value // (first write to a never-saved document, per the spec's minimum: 0), so it diff --git a/internal/cmd/gen/main.go b/internal/cmd/gen/main.go index 1c0d96b..60f97f1 100644 --- a/internal/cmd/gen/main.go +++ b/internal/cmd/gen/main.go @@ -1170,8 +1170,9 @@ 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). Callers use this both to emit `,omitzero` for request values and to -// pointer-wrap nullable or absence-preserving fields. +// allOf, and flattening a named oneOf of object arms into one struct). Callers +// use this both to emit `,omitzero` for request values and to pointer-wrap +// nullable or absence-preserving fields. func (g *Gen) isStructSchema(s map[string]any) bool { if s == nil { return false @@ -1180,7 +1181,12 @@ func (g *Gen) isStructSchema(s map[string]any) bool { if g.skip[ref] { return false } - return g.isStructSchema(asMap(g.schemas[ref])) + // emitModels queues a named schema as g.resolveObject(name), which + // flattens a oneOf of object arms into a single struct. Classify that + // emitted shape, not the raw oneOf, or an optional field of such a type + // gets `,omitempty` — a no-op on a struct value — and an unset field + // goes on the wire as an object holding only its zero discriminator. + return g.isStructSchema(g.resolveObject(ref)) } if len(asSlice(s["allOf"])) > 0 { s = g.mergeAllOf(s) diff --git a/internal/cmd/gen/main_test.go b/internal/cmd/gen/main_test.go index fb85e86..1ba387f 100644 --- a/internal/cmd/gen/main_test.go +++ b/internal/cmd/gen/main_test.go @@ -351,6 +351,51 @@ func TestEmitStructPreserveAbsenceRequestObjectPointerWraps(t *testing.T) { } } +// TestEmitStructOptionalFlattenedOneOfRequestFieldOmitsZero covers an optional +// request field whose $ref target is a oneOf of object arms. emitModels +// flattens that oneOf into one struct, so the field is a bare struct value and +// needs `,omitzero`: `,omitempty` never drops a struct, and the unset field +// would reach the wire as an object holding only an empty discriminator. +func TestEmitStructOptionalFlattenedOneOfRequestFieldOmitsZero(t *testing.T) { + g := newTestGen(map[string]any{ + "PromQuery": map[string]any{ + "type": "object", + "required": []any{"kind"}, + "properties": map[string]any{ + "kind": map[string]any{"type": "string"}, + "metric": map[string]any{"type": "string"}, + }, + }, + "LogsQuery": map[string]any{ + "type": "object", + "required": []any{"kind"}, + "properties": map[string]any{ + "kind": map[string]any{"type": "string"}, + "field": map[string]any{"type": "string"}, + }, + }, + "VariableQuery": map[string]any{ + "oneOf": []any{ + map[string]any{"$ref": "#/components/schemas/PromQuery"}, + map[string]any{"$ref": "#/components/schemas/LogsQuery"}, + }, + }, + }) + g.reqGoNames["Variable"] = true + + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "variable_query": map[string]any{"$ref": "#/components/schemas/VariableQuery"}, + }, + } + + src := g.emitStruct("Variable", schema) + if !strings.Contains(src, "VariableQuery VariableQuery `json:\"variable_query,omitzero\"") { + t.Fatalf("optional flattened-oneOf request field must use omitzero; got:\n%s", src) + } +} + // TestOpClassificationPredicates pins the three-way split of non-standard // operations: ndjson streams and non-JSON request bodies are hand-written, // while bounded binary downloads generate ordinary raw methods. diff --git a/models_gen.go b/models_gen.go index 8c34cf6..3a672e7 100644 --- a/models_gen.go +++ b/models_gen.go @@ -3696,7 +3696,7 @@ type DashboardVariable struct { // When the candidates are recomputed: `on_dashboard_load` = once when the dashboard loads; `on_time_range_change` = every time the time range changes. Refresh string `json:"refresh,omitempty" toon:"refresh,omitempty"` Selection DashboardSelectionConfig `json:"selection,omitzero" toon:"selection,omitempty"` - VariableQuery DashboardVariableQuery `json:"variable_query,omitempty" toon:"variable_query,omitempty"` + VariableQuery DashboardVariableQuery `json:"variable_query,omitzero" toon:"variable_query,omitempty"` } // DashboardVariableQuery is generated from the Flashduty OpenAPI schema.