← back to Cli Printing Press
feat(cli): emit nested-object body fields as parent-prefixed flags (#957)
ebc8cd811e5708e4b43e38b36d42719df09b4fe8 · 2026-05-10 13:06:15 -0700 · Trevin Chow
* feat(cli): emit nested-object body fields as parent-prefixed flags
Closes #942.
When a body Param declared `type: object` with non-empty Fields, the
generator previously ignored the nested structure and emitted a single
JSON-string flag (e.g., `--start='{"dateTime":"...","timeZone":"..."}'`).
Microsoft Graph and other APIs that wrap properties under DateTimeTimeZone,
Address, Name, etc. forced users to hand-write JSON for any object body
field.
bodyMap now recurses into Param.Fields and builds a `map[string]any`
that only sets the parent key when at least one child flag was provided.
Three new template helpers (bodyVarDecls, bodyFlagRegs,
bodyRequiredChecks) consolidate the var-decl / cobra-flag-reg /
required-check loops into Go so all three sites recurse uniformly with
parent-prefixed identifiers (so `start.dateTime` and `end.dateTime`
share neither variable nor flag name).
Multipart and form-encoded endpoints stay flat: those paths serialize
object-typed parents through multipartBodyMaps / formBodyMaps as a
single JSON-string field, so flattening parents would break them.
Generated CLIs that already had no nested body fields produce
byte-identical output (golden harness covers all 16 fixtures).
* fix(cli): thread parent flag prefix through renderBodyMap error messages
Greptile review on #957 caught that renderBodyMap had no flagPrefix
parameter. When a leaf inside a parent's Fields was itself routed
through the JSON-string parse path (an array leaf, or an
object-without-Fields leaf), the emitted error message named the leaf
flag only — misleading because bodyFlagRegs registers the flag with
the parent prefix.
Threads flagPrefix recursively. Adds a regression test asserting the
emitted error uses --metadata-tags rather than --tags when tags is an
array leaf inside a metadata object.
Files touched
M internal/generator/body_map_test.goA internal/generator/body_nested_object_test.goM internal/generator/generator.goM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmpl
Diff
commit ebc8cd811e5708e4b43e38b36d42719df09b4fe8
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 13:06:15 2026 -0700
feat(cli): emit nested-object body fields as parent-prefixed flags (#957)
* feat(cli): emit nested-object body fields as parent-prefixed flags
Closes #942.
When a body Param declared `type: object` with non-empty Fields, the
generator previously ignored the nested structure and emitted a single
JSON-string flag (e.g., `--start='{"dateTime":"...","timeZone":"..."}'`).
Microsoft Graph and other APIs that wrap properties under DateTimeTimeZone,
Address, Name, etc. forced users to hand-write JSON for any object body
field.
bodyMap now recurses into Param.Fields and builds a `map[string]any`
that only sets the parent key when at least one child flag was provided.
Three new template helpers (bodyVarDecls, bodyFlagRegs,
bodyRequiredChecks) consolidate the var-decl / cobra-flag-reg /
required-check loops into Go so all three sites recurse uniformly with
parent-prefixed identifiers (so `start.dateTime` and `end.dateTime`
share neither variable nor flag name).
Multipart and form-encoded endpoints stay flat: those paths serialize
object-typed parents through multipartBodyMaps / formBodyMaps as a
single JSON-string field, so flattening parents would break them.
Generated CLIs that already had no nested body fields produce
byte-identical output (golden harness covers all 16 fixtures).
* fix(cli): thread parent flag prefix through renderBodyMap error messages
Greptile review on #957 caught that renderBodyMap had no flagPrefix
parameter. When a leaf inside a parent's Fields was itself routed
through the JSON-string parse path (an array leaf, or an
object-without-Fields leaf), the emitted error message named the leaf
flag only — misleading because bodyFlagRegs registers the flag with
the parent prefix.
Threads flagPrefix recursively. Adds a regression test asserting the
emitted error uses --metadata-tags rather than --tags when tags is an
array leaf inside a metadata object.
---
internal/generator/body_map_test.go | 299 +++++++++++++++++++++
internal/generator/body_nested_object_test.go | 123 +++++++++
internal/generator/generator.go | 188 ++++++++++++-
.../generator/templates/command_endpoint.go.tmpl | 29 +-
.../generator/templates/command_promoted.go.tmpl | 21 +-
5 files changed, 605 insertions(+), 55 deletions(-)
diff --git a/internal/generator/body_map_test.go b/internal/generator/body_map_test.go
index adeafb13..6e1f0b05 100644
--- a/internal/generator/body_map_test.go
+++ b/internal/generator/body_map_test.go
@@ -143,3 +143,302 @@ func TestBodyMap_IdentName(t *testing.T) {
t.Errorf("expected wire key to use Name (not IdentName), got: %s", got)
}
}
+
+// TestBodyMap_NestedObject verifies that body params declaring
+// type=object with non-empty Fields render a nested-map block in
+// place of the JSON-string parse path. The wire key is the parent's
+// Name; field keys are each leaf's Name. Field-flag variables are
+// parent-prefixed (bodyStartDateTime, not bodyDateTime) so two
+// parents that share a field name do not collide.
+func TestBodyMap_NestedObject(t *testing.T) {
+ t.Parallel()
+ got := bodyMap([]spec.Param{{
+ Name: "start",
+ Type: "object",
+ Fields: []spec.Param{
+ {Name: "dateTime", Type: "string"},
+ {Name: "timeZone", Type: "string"},
+ },
+ }}, "\t")
+ want := "\t{\n" +
+ "\t\tnestedStart := map[string]any{}\n" +
+ "\t\tif bodyStartDateTime != \"\" {\n" +
+ "\t\t\tnestedStart[\"dateTime\"] = bodyStartDateTime\n" +
+ "\t\t}\n" +
+ "\t\tif bodyStartTimeZone != \"\" {\n" +
+ "\t\t\tnestedStart[\"timeZone\"] = bodyStartTimeZone\n" +
+ "\t\t}\n" +
+ "\t\tif len(nestedStart) > 0 {\n" +
+ "\t\t\tbody[\"start\"] = nestedStart\n" +
+ "\t\t}\n" +
+ "\t}\n"
+ if got != want {
+ t.Errorf("bodyMap nested mismatch.\n got:\n%s\nwant:\n%s", got, want)
+ }
+}
+
+// TestBodyMap_NestedObject_PreservesScalarSiblings verifies that
+// nested and flat body params can coexist: nested produces a block,
+// scalars keep their existing if-then-set form.
+func TestBodyMap_NestedObject_PreservesScalarSiblings(t *testing.T) {
+ t.Parallel()
+ got := bodyMap([]spec.Param{
+ {Name: "subject", Type: "string"},
+ {Name: "start", Type: "object", Fields: []spec.Param{{Name: "dateTime", Type: "string"}}},
+ }, "\t")
+ if !strings.Contains(got, `if bodySubject != "" {`) {
+ t.Errorf("scalar branch missing, got:\n%s", got)
+ }
+ if !strings.Contains(got, `body["subject"] = bodySubject`) {
+ t.Errorf("scalar wire-set missing, got:\n%s", got)
+ }
+ if !strings.Contains(got, `nestedStart := map[string]any{}`) {
+ t.Errorf("nested-map declaration missing, got:\n%s", got)
+ }
+ if !strings.Contains(got, `nestedStart["dateTime"] = bodyStartDateTime`) {
+ t.Errorf("nested-field set missing, got:\n%s", got)
+ }
+}
+
+// TestBodyMap_NestedObject_EmptyFieldsKeepsJSONStringPath verifies the
+// non-recursive case: an object body param with no Fields keeps the
+// existing JSON-string parse-and-store path so OpenAPI specs that lack
+// nested-property metadata are unaffected.
+func TestBodyMap_NestedObject_EmptyFieldsKeepsJSONStringPath(t *testing.T) {
+ t.Parallel()
+ got := bodyMap([]spec.Param{{Name: "metadata", Type: "object"}}, "\t")
+ if !strings.Contains(got, "json.Unmarshal([]byte(bodyMetadata)") {
+ t.Errorf("expected JSON-parse path for object without Fields, got:\n%s", got)
+ }
+ if strings.Contains(got, "nestedMetadata") {
+ t.Errorf("object without Fields must not emit a nested-map block, got:\n%s", got)
+ }
+}
+
+// TestBodyMap_NestedJSONStringLeafUsesParentPrefixedFlag verifies that
+// when a leaf inside a parent's Fields is itself routed through the
+// JSON-string parse path (an array, or an object without Fields), the
+// emitted error message uses the parent-prefixed flag name. Without
+// flagPrefix threading, the error would name only the leaf — misleading
+// users who set `--metadata-tags` and saw "parsing --tags JSON" on
+// failure.
+func TestBodyMap_NestedJSONStringLeafUsesParentPrefixedFlag(t *testing.T) {
+ t.Parallel()
+ got := bodyMap([]spec.Param{{
+ Name: "metadata",
+ Type: "object",
+ Fields: []spec.Param{
+ {Name: "tags", Type: "array"},
+ },
+ }}, "\t")
+ if !strings.Contains(got, `"parsing --metadata-tags JSON: %w"`) {
+ t.Errorf("expected parent-prefixed flag in error message, got:\n%s", got)
+ }
+ if strings.Contains(got, `"parsing --tags JSON: %w"`) {
+ t.Errorf("error must not name leaf-only flag (the registered flag is parent-prefixed), got:\n%s", got)
+ }
+}
+
+// TestBodyMap_DeepNesting verifies that nesting recurses past one
+// level. A spec where a parent.child both declare Fields should produce
+// nested blocks two levels deep, with parent-prefixed identifiers
+// flowing through unchanged.
+func TestBodyMap_DeepNesting(t *testing.T) {
+ t.Parallel()
+ got := bodyMap([]spec.Param{{
+ Name: "filter",
+ Type: "object",
+ Fields: []spec.Param{{
+ Name: "range",
+ Type: "object",
+ Fields: []spec.Param{
+ {Name: "min", Type: "int"},
+ {Name: "max", Type: "int"},
+ },
+ }},
+ }}, "\t")
+ if !strings.Contains(got, "nestedFilter") || !strings.Contains(got, "nestedFilterRange") {
+ t.Errorf("expected two-level nested-map declarations, got:\n%s", got)
+ }
+ if !strings.Contains(got, `nestedFilterRange["min"] = bodyFilterRangeMin`) {
+ t.Errorf("expected parent-prefixed leaf set, got:\n%s", got)
+ }
+ if !strings.Contains(got, `nestedFilter["range"] = nestedFilterRange`) {
+ t.Errorf("expected child map assigned to parent map, got:\n%s", got)
+ }
+}
+
+// TestBodyVarDecls_Flat pins the flat-case output so existing CLIs
+// (no nested fields) do not see any generator-output diff after the
+// helper takes over from the inline `{{- range .Endpoint.Body}}` loop.
+func TestBodyVarDecls_Flat(t *testing.T) {
+ t.Parallel()
+ got := bodyVarDecls(spec.Endpoint{
+ Body: []spec.Param{
+ {Name: "name", Type: "string"},
+ {Name: "count", Type: "int"},
+ },
+ })
+ want := "\n\tvar bodyName string\n\tvar bodyCount int"
+ if got != want {
+ t.Errorf("bodyVarDecls flat mismatch.\n got:%q\nwant:%q", got, want)
+ }
+}
+
+// TestBodyVarDecls_Nested expands a single nested-object body param
+// into one var per leaf field with parent-prefixed identifiers, and
+// emits no var for the parent itself.
+func TestBodyVarDecls_Nested(t *testing.T) {
+ t.Parallel()
+ got := bodyVarDecls(spec.Endpoint{
+ Body: []spec.Param{{
+ Name: "start",
+ Type: "object",
+ Fields: []spec.Param{
+ {Name: "dateTime", Type: "string"},
+ {Name: "timeZone", Type: "string"},
+ },
+ }},
+ })
+ want := "\n\tvar bodyStartDateTime string\n\tvar bodyStartTimeZone string"
+ if got != want {
+ t.Errorf("bodyVarDecls nested mismatch.\n got:%q\nwant:%q", got, want)
+ }
+ if strings.Contains(got, "bodyStart string") {
+ t.Errorf("parent var must not be declared when Fields populated, got:%q", got)
+ }
+}
+
+// TestBodyVarDecls_NonJSONStaysFlat verifies that multipart and
+// form-encoded endpoints preserve the flat var-declaration shape so
+// multipartBodyMaps and formBodyMaps (which serialize object-typed
+// parents as JSON-string fields) still have the parent variable to read
+// from.
+func TestBodyVarDecls_NonJSONStaysFlat(t *testing.T) {
+ t.Parallel()
+ for _, contentType := range []string{"multipart/form-data", "application/x-www-form-urlencoded"} {
+ got := bodyVarDecls(spec.Endpoint{
+ RequestContentType: contentType,
+ Body: []spec.Param{{
+ Name: "start",
+ Type: "object",
+ Fields: []spec.Param{{Name: "dateTime", Type: "string"}},
+ }},
+ })
+ want := "\n\tvar bodyStart string"
+ if got != want {
+ t.Errorf("[%s] bodyVarDecls must stay flat. got:%q want:%q", contentType, got, want)
+ }
+ }
+}
+
+// TestBodyFlagRegs_Flat pins the flat-case output for cobra flag
+// registration. Aliases follow the primary registration with
+// MarkHidden, mirroring the original template.
+func TestBodyFlagRegs_Flat(t *testing.T) {
+ t.Parallel()
+ got := bodyFlagRegs(spec.Endpoint{
+ Body: []spec.Param{
+ {Name: "name", Type: "string", Description: "Display name", Aliases: []string{"n"}},
+ },
+ })
+ want := "\n\tcmd.Flags().StringVar(&bodyName, \"name\", \"\", \"Display name\")" +
+ "\n\tcmd.Flags().StringVar(&bodyName, \"n\", \"\", \"Display name\")" +
+ "\n\t_ = cmd.Flags().MarkHidden(\"n\")"
+ if got != want {
+ t.Errorf("bodyFlagRegs flat mismatch.\n got:%q\nwant:%q", got, want)
+ }
+}
+
+// TestBodyFlagRegs_Nested registers one flag per leaf field with
+// parent-prefixed flag names so two parents that share a field name
+// (e.g. start.dateTime + end.dateTime) do not collide. Aliases are not
+// propagated to nested fields.
+func TestBodyFlagRegs_Nested(t *testing.T) {
+ t.Parallel()
+ got := bodyFlagRegs(spec.Endpoint{
+ Body: []spec.Param{{
+ Name: "start",
+ Type: "object",
+ Description: "Start of window",
+ Aliases: []string{"s"},
+ Fields: []spec.Param{
+ {Name: "dateTime", Type: "string", Description: "RFC3339 timestamp"},
+ {Name: "timeZone", Type: "string", Description: "IANA zone"},
+ },
+ }},
+ })
+ if !strings.Contains(got, "cmd.Flags().StringVar(&bodyStartDateTime, \"start-date-time\", \"\", \"RFC3339 timestamp\")") {
+ t.Errorf("expected parent-prefixed flag for nested dateTime, got:\n%s", got)
+ }
+ if !strings.Contains(got, "cmd.Flags().StringVar(&bodyStartTimeZone, \"start-time-zone\", \"\", \"IANA zone\")") {
+ t.Errorf("expected parent-prefixed flag for nested timeZone, got:\n%s", got)
+ }
+ if strings.Contains(got, "cmd.Flags().StringVar(&bodyStart, \"start\"") {
+ t.Errorf("parent flag must not be registered when Fields populated, got:\n%s", got)
+ }
+ if strings.Contains(got, "MarkHidden") {
+ t.Errorf("parent aliases must not propagate to nested fields, got:\n%s", got)
+ }
+}
+
+// TestBodyFlagRegs_NonJSONStaysFlat verifies multipart and form-encoded
+// endpoints keep the parent JSON-string flag because their body-map
+// helpers serialize object-typed parents as a single JSON string.
+func TestBodyFlagRegs_NonJSONStaysFlat(t *testing.T) {
+ t.Parallel()
+ for _, contentType := range []string{"multipart/form-data", "application/x-www-form-urlencoded"} {
+ got := bodyFlagRegs(spec.Endpoint{
+ RequestContentType: contentType,
+ Body: []spec.Param{{
+ Name: "start",
+ Type: "object",
+ Fields: []spec.Param{{Name: "dateTime", Type: "string"}},
+ }},
+ })
+ if !strings.Contains(got, "cmd.Flags().StringVar(&bodyStart, \"start\"") {
+ t.Errorf("[%s] must keep parent flag, got:\n%s", contentType, got)
+ }
+ if strings.Contains(got, "bodyStartDateTime") {
+ t.Errorf("[%s] must not emit nested flag, got:\n%s", contentType, got)
+ }
+ }
+}
+
+// TestBodyRequiredChecks_NestedField uses parent-prefixed flag in the
+// emitted `cmd.Flags().Changed(...)` call so the validator agrees with
+// the flag name registered in bodyFlagRegs.
+func TestBodyRequiredChecks_NestedField(t *testing.T) {
+ t.Parallel()
+ got := bodyRequiredChecks(spec.Endpoint{
+ Body: []spec.Param{{
+ Name: "start",
+ Type: "object",
+ Fields: []spec.Param{
+ {Name: "dateTime", Type: "string", Required: true},
+ },
+ }},
+ }, "\t\t\t")
+ if !strings.Contains(got, `cmd.Flags().Changed("start-date-time")`) {
+ t.Errorf("expected parent-prefixed Changed() call for nested required field, got:\n%s", got)
+ }
+ if !strings.Contains(got, `"required flag \"%s\" not set", "start-date-time"`) {
+ t.Errorf("expected parent-prefixed flag name in error message, got:\n%s", got)
+ }
+}
+
+// TestBodyRequiredChecks_TopLevelKeepsAliasOR verifies that top-level
+// required-flag checks still use flagChangedExpr (which ORs aliases).
+// Without this, a user passing `--n value` would fail the required
+// check even though `name` was effectively set.
+func TestBodyRequiredChecks_TopLevelKeepsAliasOR(t *testing.T) {
+ t.Parallel()
+ got := bodyRequiredChecks(spec.Endpoint{
+ Body: []spec.Param{
+ {Name: "name", Type: "string", Required: true, Aliases: []string{"n"}},
+ },
+ }, "\t\t\t")
+ if !strings.Contains(got, `(cmd.Flags().Changed("name") || cmd.Flags().Changed("n"))`) {
+ t.Errorf("expected alias-OR in required check, got:\n%s", got)
+ }
+}
diff --git a/internal/generator/body_nested_object_test.go b/internal/generator/body_nested_object_test.go
new file mode 100644
index 00000000..a306d371
--- /dev/null
+++ b/internal/generator/body_nested_object_test.go
@@ -0,0 +1,123 @@
+package generator
+
+import (
+ "go/parser"
+ "go/token"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/require"
+)
+
+// TestGenerateNestedObjectBodyEmitsFieldFlags is the end-to-end check
+// for issue #942: when a body Param declares Type "object" with non-empty
+// Fields, the generated endpoint command must expose one cobra flag per
+// leaf (parent-prefixed so siblings sharing a field name do not collide)
+// and build the wire-side body as a nested map[string]any rather than a
+// JSON-string-only flag. Without this fix, Microsoft Graph and similar
+// APIs that wrap dateTime/timeZone (or address/line1/line2/...) under a
+// single object property require users to hand-write JSON.
+func TestGenerateNestedObjectBodyEmitsFieldFlags(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("nested-body")
+ apiSpec.Resources["events"] = spec.Resource{
+ Description: "Calendar events",
+ Endpoints: map[string]spec.Endpoint{
+ "create": {
+ Method: "POST",
+ Path: "/events",
+ Description: "Create a calendar event",
+ Body: []spec.Param{
+ {Name: "subject", Type: "string", Description: "Event title", Required: true},
+ {
+ Name: "start",
+ Type: "object",
+ Description: "Start of window",
+ Fields: []spec.Param{
+ {Name: "dateTime", Type: "string", Description: "RFC3339 timestamp", Required: true},
+ {Name: "timeZone", Type: "string", Description: "IANA zone"},
+ },
+ },
+ {
+ Name: "end",
+ Type: "object",
+ Fields: []spec.Param{
+ {Name: "dateTime", Type: "string", Description: "RFC3339 timestamp"},
+ {Name: "timeZone", Type: "string", Description: "IANA zone"},
+ },
+ },
+ },
+ },
+ "get": {
+ Method: "GET",
+ Path: "/events/{id}",
+ Description: "Get one event",
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "nested-body-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ src, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "events_create.go"))
+ require.NoError(t, err)
+ got := string(src)
+
+ // Vars: parent-prefixed leaf decls only; no parent var for object-with-Fields.
+ for _, want := range []string{
+ "var bodyStartDateTime string",
+ "var bodyStartTimeZone string",
+ "var bodyEndDateTime string",
+ "var bodyEndTimeZone string",
+ "var bodySubject string",
+ } {
+ require.Containsf(t, got, want, "expected leaf var %q in generated file", want)
+ }
+ require.NotContainsf(t, got, "var bodyStart string", "parent var must not appear when Fields populated")
+ require.NotContainsf(t, got, "var bodyEnd string", "parent var must not appear when Fields populated")
+
+ // Flag registrations: parent-prefixed flag names so start.dateTime and
+ // end.dateTime do not collide on a single --date-time flag.
+ for _, want := range []string{
+ `cmd.Flags().StringVar(&bodyStartDateTime, "start-date-time"`,
+ `cmd.Flags().StringVar(&bodyStartTimeZone, "start-time-zone"`,
+ `cmd.Flags().StringVar(&bodyEndDateTime, "end-date-time"`,
+ `cmd.Flags().StringVar(&bodyEndTimeZone, "end-time-zone"`,
+ `cmd.Flags().StringVar(&bodySubject, "subject"`,
+ } {
+ require.Containsf(t, got, want, "expected flag registration %q", want)
+ }
+
+ // Required-flag validation: parent-prefixed flag in the error message
+ // matches the registered flag name.
+ require.Contains(t, got, `cmd.Flags().Changed("start-date-time")`, "required check must use parent-prefixed flag")
+ require.Contains(t, got, `"required flag \"%s\" not set", "start-date-time"`)
+
+ // Body construction: nested map literal that only sets the parent key
+ // when at least one child field was provided.
+ for _, want := range []string{
+ "nestedStart := map[string]any{}",
+ `nestedStart["dateTime"] = bodyStartDateTime`,
+ `nestedStart["timeZone"] = bodyStartTimeZone`,
+ `if len(nestedStart) > 0 {`,
+ `body["start"] = nestedStart`,
+ "nestedEnd := map[string]any{}",
+ `body["end"] = nestedEnd`,
+ } {
+ require.Containsf(t, got, want, "expected nested-map fragment %q", want)
+ }
+
+ // Make sure the generated file still parses as Go (catches whitespace
+ // or scope mistakes in template wiring that the snippet matches above
+ // would otherwise miss).
+ if !strings.Contains(got, "package cli") {
+ t.Fatalf("generated file missing 'package cli' header")
+ }
+ fset := token.NewFileSet()
+ _, parseErr := parser.ParseFile(fset, "events_create.go", got, parser.AllErrors)
+ require.NoError(t, parseErr, "generated file with nested-object body must parse as Go")
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 77c2edff..3d1efb1e 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -308,6 +308,9 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"jsonStringParam": isJSONStringParam,
"jsonEnumSuggestion": jsonEnumSuggestion,
"bodyMap": bodyMap,
+ "bodyVarDecls": bodyVarDecls,
+ "bodyFlagRegs": bodyFlagRegs,
+ "bodyRequiredChecks": bodyRequiredChecks,
"multipartBodyMaps": multipartBodyMaps,
"endpointUsesMultipart": endpointUsesMultipart,
"hasMultipartRequest": hasMultipartRequest,
@@ -3124,12 +3127,34 @@ func endpointNeedsClientLimit(endpoint spec.Endpoint) bool {
// and parameterizes the indent. The output is the body of the
// `body := map[string]any{}` block — callers emit the surrounding
// declaration and the closing brace themselves.
+//
+// When a body Param has Type "object" with non-empty Fields, the block
+// recurses: each leaf field becomes its own flag (parent-prefixed in
+// the generated identifier so `start.dateTime` and `end.dateTime` do
+// not collide), and the parent's wire-side key receives a built-up
+// map[string]any rather than a single JSON-string flag.
func bodyMap(body []spec.Param, indent string) string {
var b strings.Builder
+ renderBodyMap(&b, body, indent, "body", "", "")
+ return b.String()
+}
+
+func renderBodyMap(b *strings.Builder, body []spec.Param, indent, mapVar, identPrefix, flagPrefix string) {
for _, p := range body {
id := paramIdent(p)
- ident := toCamel(id)
- flag := publicFlagName(p)
+ ident := identPrefix + toCamel(id)
+ flag := joinFlag(flagPrefix, publicFlagName(p))
+ if p.Type == "object" && len(p.Fields) > 0 {
+ nestedMap := "nested" + ident
+ fmt.Fprintf(b, "%s{\n", indent)
+ fmt.Fprintf(b, "%s\t%s := map[string]any{}\n", indent, nestedMap)
+ renderBodyMap(b, p.Fields, indent+"\t", nestedMap, ident, flag)
+ fmt.Fprintf(b, "%s\tif len(%s) > 0 {\n", indent, nestedMap)
+ fmt.Fprintf(b, "%s\t\t%s[%q] = %s\n", indent, mapVar, p.Name, nestedMap)
+ fmt.Fprintf(b, "%s\t}\n", indent)
+ fmt.Fprintf(b, "%s}\n", indent)
+ continue
+ }
isComplex := p.Type == "object" || p.Type == "array"
if isComplex || isJSONStringParam(p) {
// object/array: store the parsed value (so the API receives
@@ -3139,22 +3164,161 @@ func bodyMap(body []spec.Param, indent string) string {
if isComplex {
rhs = "parsed" + ident
}
- fmt.Fprintf(&b, "%sif body%s != \"\" {\n", indent, ident)
- fmt.Fprintf(&b, "%s\tvar parsed%s any\n", indent, ident)
- fmt.Fprintf(&b, "%s\tif err := json.Unmarshal([]byte(body%s), &parsed%s); err != nil {\n", indent, ident, ident)
- fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"parsing --%s JSON: %%w\", err)\n", indent, flag)
- fmt.Fprintf(&b, "%s\t}\n", indent)
- fmt.Fprintf(&b, "%s\tbody[%q] = %s\n", indent, p.Name, rhs)
- fmt.Fprintf(&b, "%s}\n", indent)
+ fmt.Fprintf(b, "%sif body%s != \"\" {\n", indent, ident)
+ fmt.Fprintf(b, "%s\tvar parsed%s any\n", indent, ident)
+ fmt.Fprintf(b, "%s\tif err := json.Unmarshal([]byte(body%s), &parsed%s); err != nil {\n", indent, ident, ident)
+ fmt.Fprintf(b, "%s\t\treturn fmt.Errorf(\"parsing --%s JSON: %%w\", err)\n", indent, flag)
+ fmt.Fprintf(b, "%s\t}\n", indent)
+ fmt.Fprintf(b, "%s\t%s[%q] = %s\n", indent, mapVar, p.Name, rhs)
+ fmt.Fprintf(b, "%s}\n", indent)
continue
}
- fmt.Fprintf(&b, "%sif body%s != %s {\n", indent, ident, zeroVal(p.Type))
- fmt.Fprintf(&b, "%s\tbody[%q] = body%s\n", indent, p.Name, ident)
- fmt.Fprintf(&b, "%s}\n", indent)
+ fmt.Fprintf(b, "%sif body%s != %s {\n", indent, ident, zeroVal(p.Type))
+ fmt.Fprintf(b, "%s\t%s[%q] = body%s\n", indent, mapVar, p.Name, ident)
+ fmt.Fprintf(b, "%s}\n", indent)
+ }
+}
+
+// bodyVarDecls renders Go var declarations for body construction. For
+// JSON-body endpoints, nested-object params (Type "object" with non-empty
+// Fields) recurse: each leaf field becomes its own declaration with a
+// parent-prefixed identifier. For multipart and form-encoded endpoints,
+// the body path stays flat (one var per top-level param) because
+// multipartBodyMaps and formBodyMaps continue to send object-typed
+// parents as JSON-string fields. Output starts with "\n\tvar ..."
+// matching the one-tab indent of the original `{{- range .Endpoint.Body}}`
+// template loop.
+func bodyVarDecls(endpoint spec.Endpoint) string {
+ var b strings.Builder
+ if bodyUsesFlatEmission(endpoint) {
+ for _, p := range endpoint.Body {
+ fmt.Fprintf(&b, "\n\tvar body%s %s", toCamel(paramIdent(p)), goType(p.Type))
+ }
+ return b.String()
+ }
+ renderBodyVarDecls(&b, endpoint.Body, "")
+ return b.String()
+}
+
+// bodyUsesFlatEmission reports whether the endpoint serializes its body
+// via a non-JSON-map path (multipart/form-data or
+// application/x-www-form-urlencoded). Those paths keep one var/flag per
+// top-level body param so multipartBodyMaps / formBodyMaps still find
+// the parent variable to read from.
+func bodyUsesFlatEmission(endpoint spec.Endpoint) bool {
+ return endpointUsesMultipart(endpoint) || endpointUsesForm(endpoint)
+}
+
+func renderBodyVarDecls(b *strings.Builder, body []spec.Param, identPrefix string) {
+ for _, p := range body {
+ ident := identPrefix + toCamel(paramIdent(p))
+ if p.Type == "object" && len(p.Fields) > 0 {
+ renderBodyVarDecls(b, p.Fields, ident)
+ continue
+ }
+ fmt.Fprintf(b, "\n\tvar body%s %s", ident, goType(p.Type))
+ }
+}
+
+// bodyFlagRegs renders cobra flag registrations for body construction.
+// Multipart endpoints keep flat (one flag per top-level body param) so
+// the JSON-string fallback that multipartBodyMaps emits stays addressable.
+// For non-multipart endpoints, nested-object params recurse with
+// parent-prefixed flag names so two parents that share a field name do
+// not collide. Aliases are emitted only at the top level.
+func bodyFlagRegs(endpoint spec.Endpoint) string {
+ var b strings.Builder
+ if bodyUsesFlatEmission(endpoint) {
+ for _, p := range endpoint.Body {
+ renderFlatBodyFlagReg(&b, p, "", "", true)
+ }
+ return b.String()
}
+ renderBodyFlagRegs(&b, endpoint.Body, "", "", true)
return b.String()
}
+func renderBodyFlagRegs(b *strings.Builder, body []spec.Param, identPrefix, flagPrefix string, topLevel bool) {
+ for _, p := range body {
+ if p.Type == "object" && len(p.Fields) > 0 {
+ ident := identPrefix + toCamel(paramIdent(p))
+ flag := joinFlag(flagPrefix, publicFlagName(p))
+ renderBodyFlagRegs(b, p.Fields, ident, flag, false)
+ continue
+ }
+ renderFlatBodyFlagReg(b, p, identPrefix, flagPrefix, topLevel)
+ }
+}
+
+func renderFlatBodyFlagReg(b *strings.Builder, p spec.Param, identPrefix, flagPrefix string, topLevel bool) {
+ ident := identPrefix + toCamel(paramIdent(p))
+ flag := joinFlag(flagPrefix, publicFlagName(p))
+ desc := naming.OneLine(p.Description)
+ fmt.Fprintf(b, "\n\tcmd.Flags().%s(&body%s, \"%s\", %s, \"%s\")",
+ cobraFlagFunc(p.Type), ident, flag, defaultVal(p), desc)
+ if topLevel {
+ for _, alias := range publicFlagAliases(p) {
+ fmt.Fprintf(b, "\n\tcmd.Flags().%s(&body%s, \"%s\", %s, \"%s\")",
+ cobraFlagFunc(p.Type), ident, alias, defaultVal(p), desc)
+ fmt.Fprintf(b, "\n\t_ = cmd.Flags().MarkHidden(\"%s\")", alias)
+ }
+ }
+}
+
+// bodyRequiredChecks renders required-flag validation for body params.
+// indent is the indent prefix applied to each emitted `if` line so the
+// helper can serve both command_endpoint.go.tmpl (4-tab indent inside
+// `if !stdinBody`, 3-tab indent for multipart) and command_promoted.go.tmpl
+// (3-tab indent at RunE-body level). Multipart endpoints keep flat
+// behavior. For non-multipart, top-level params use flagChangedExpr
+// (lifts aliases); nested fields use a single Changed() check on the
+// parent-prefixed flag because aliases are not propagated to children.
+func bodyRequiredChecks(endpoint spec.Endpoint, indent string) string {
+ var b strings.Builder
+ if bodyUsesFlatEmission(endpoint) {
+ for _, p := range endpoint.Body {
+ renderFlatBodyRequiredCheck(&b, p, indent, "", true)
+ }
+ return b.String()
+ }
+ renderBodyRequiredChecks(&b, endpoint.Body, indent, "", true)
+ return b.String()
+}
+
+func renderBodyRequiredChecks(b *strings.Builder, body []spec.Param, indent, flagPrefix string, topLevel bool) {
+ for _, p := range body {
+ if p.Type == "object" && len(p.Fields) > 0 {
+ flag := joinFlag(flagPrefix, publicFlagName(p))
+ renderBodyRequiredChecks(b, p.Fields, indent, flag, false)
+ continue
+ }
+ renderFlatBodyRequiredCheck(b, p, indent, flagPrefix, topLevel)
+ }
+}
+
+func renderFlatBodyRequiredCheck(b *strings.Builder, p spec.Param, indent, flagPrefix string, topLevel bool) {
+ if !p.Required || p.Default != nil {
+ return
+ }
+ flag := joinFlag(flagPrefix, publicFlagName(p))
+ var changedExpr string
+ if topLevel {
+ changedExpr = flagChangedExpr(p)
+ } else {
+ changedExpr = fmt.Sprintf("cmd.Flags().Changed(\"%s\")", flag)
+ }
+ fmt.Fprintf(b, "\n%sif !%s && !flags.dryRun {", indent, changedExpr)
+ fmt.Fprintf(b, "\n%s\treturn fmt.Errorf(\"required flag \\\"%%s\\\" not set\", \"%s\")", indent, flag)
+ fmt.Fprintf(b, "\n%s}", indent)
+}
+
+func joinFlag(prefix, name string) string {
+ if prefix == "" {
+ return name
+ }
+ return prefix + "-" + name
+}
+
func multipartBodyMaps(body []spec.Param, indent string) string {
var b strings.Builder
for _, p := range body {
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 1d81ae4a..23fd1638 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -35,9 +35,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
var flag{{camel (paramIdent .)}} {{goTypeForParam .Name .Type}}
{{- end}}
{{- end}}
-{{- range .Endpoint.Body}}
- var body{{camel (paramIdent .)}} {{goType .Type}}
-{{- end}}
+{{- bodyVarDecls .Endpoint}}
{{- if .Endpoint.Pagination}}
var flagAll bool
{{- end}}
@@ -108,22 +106,10 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
{{- if or $isMultipart $isForm}}
-{{- range .Endpoint.Body}}
-{{- if and .Required (not .Default)}}
- if !{{flagChangedExpr .}} && !flags.dryRun {
- return fmt.Errorf("required flag \"%s\" not set", "{{publicFlagName .}}")
- }
-{{- end}}
-{{- end}}
+{{- bodyRequiredChecks .Endpoint "\t\t\t"}}
{{- else}}
if !stdinBody {
-{{- range .Endpoint.Body}}
-{{- if and .Required (not .Default)}}
- if !{{flagChangedExpr .}} && !flags.dryRun {
- return fmt.Errorf("required flag \"%s\" not set", "{{publicFlagName .}}")
- }
-{{- end}}
-{{- end}}
+{{- bodyRequiredChecks .Endpoint "\t\t\t\t"}}
}
{{- end}}
{{- end}}
@@ -583,14 +569,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- end}}
{{- end}}
-{{- range .Endpoint.Body}}
-{{- $param := .}}
- cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel (paramIdent .)}}, "{{publicFlagName .}}", {{defaultVal .}}, "{{oneline .Description}}")
-{{- range publicFlagAliases $param}}
- cmd.Flags().{{cobraFlagFunc $param.Type}}(&body{{camel (paramIdent $param)}}, "{{.}}", {{defaultVal $param}}, "{{oneline $param.Description}}")
- _ = cmd.Flags().MarkHidden("{{.}}")
-{{- end}}
-{{- end}}
+{{- bodyFlagRegs .Endpoint}}
{{- if .Endpoint.Pagination}}
cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
{{- end}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 6aecf4b3..f899bdf7 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -22,9 +22,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
var flag{{camel (paramIdent .)}} {{goTypeForParam .Name .Type}}
{{- end}}
{{- end}}
-{{- range .Endpoint.Body}}
- var body{{camel (paramIdent .)}} {{goType .Type}}
-{{- end}}
+{{- bodyVarDecls .Endpoint}}
{{- if .Endpoint.Pagination}}
var flagAll bool
{{- end}}
@@ -43,13 +41,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
}
{{- end}}
{{- end}}
-{{- range .Endpoint.Body}}
-{{- if and .Required (not .Default)}}
- if !{{flagChangedExpr .}} && !flags.dryRun {
- return fmt.Errorf("required flag \"%s\" not set", "{{publicFlagName .}}")
- }
-{{- end}}
-{{- end}}
+{{- bodyRequiredChecks .Endpoint "\t\t\t"}}
{{- range .Endpoint.Params}}
{{- if and .Enum (not .Positional) (eq .Type "string")}}
if {{flagChangedExpr .}} {
@@ -297,14 +289,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- end}}
{{- end}}
{{- end}}
-{{- range .Endpoint.Body}}
-{{- $param := .}}
- cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel (paramIdent .)}}, "{{publicFlagName .}}", {{defaultVal .}}, "{{oneline .Description}}")
-{{- range publicFlagAliases $param}}
- cmd.Flags().{{cobraFlagFunc $param.Type}}(&body{{camel (paramIdent $param)}}, "{{.}}", {{defaultVal $param}}, "{{oneline $param.Description}}")
- _ = cmd.Flags().MarkHidden("{{.}}")
-{{- end}}
-{{- end}}
+{{- bodyFlagRegs .Endpoint}}
{{- if .Endpoint.Pagination}}
cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
{{- end}}
← ff755316 fix(cli): isolate generic resources by type (#901)
·
back to Cli Printing Press
·
fix(cli): route explicit --csv/--quiet/--plain above piped-p ab6edbef →