[object Object]

← 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

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 →