← back to Cli Printing Press
fix(cli): cap body-flag recursion depth to prevent compiler OOM (#1522)
82ad787b8a38591e611fdc968630cda45a27e28f · 2026-05-16 12:29:12 -0700 · Trevin Chow
* fix(cli): cap body-flag recursion depth to prevent compiler OOM
Deeply nested OpenAPI request bodies (Tripletex, NetSuite, SAP S/4HANA, and
similar enterprise specs) produced POST/PUT command files with 40k+ lines as
the body-flag emitters recursed through every nested object. The Go compiler
ran out of memory (signal: killed) before finishing the package.
Caps recursion at maxBodyFlagDepth = 3 across the four body-emitter helpers:
renderBodyMap, renderBodyVarDecls, renderBodyFlagRegs, renderBodyRequiredChecks.
When the next depth would meet the cap, the object's subtree is skipped
uniformly across all four; deeper fields are reachable via the existing
--stdin flag on POST/PUT/PATCH commands.
The --stdin flag's help text is rewritten when truncation fires so an
operator inspecting --help sees the affordance without reading the issue
tracker. Behavior for shallow specs (<= 2 levels of nesting) is unchanged
across all 20 golden fixtures.
Closes #1240
Refs #1520
* fix(cli): walk flattened body for depth-cap truncation signal
bodyExceedsFlagDepth read endpoint.Body directly, but the four emitters
walk flattenCollidingBodyFields(endpoint.Body). When dot-flattened
identifier collision clears an object's Fields, the emitters treat that
subtree as a JSON-string leaf and never recurse past it, so no depth-cap
truncation actually occurs. The predicate, however, still saw the
original deep structure and returned true, rewriting the --stdin help
text to the truncation-warning variant even though every field was
exposed as a per-field flag.
Walk flattenCollidingBodyFields in the predicate so detection and
emission cannot drift. Adds a regression test for the
collision-flattened deep subtree case.
Refs #1240
---------
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Files touched
M internal/generator/body_collision_test.goM internal/generator/body_map_test.goM internal/generator/generator.goM internal/generator/templates/command_endpoint.go.tmpl
Diff
commit 82ad787b8a38591e611fdc968630cda45a27e28f
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat May 16 12:29:12 2026 -0700
fix(cli): cap body-flag recursion depth to prevent compiler OOM (#1522)
* fix(cli): cap body-flag recursion depth to prevent compiler OOM
Deeply nested OpenAPI request bodies (Tripletex, NetSuite, SAP S/4HANA, and
similar enterprise specs) produced POST/PUT command files with 40k+ lines as
the body-flag emitters recursed through every nested object. The Go compiler
ran out of memory (signal: killed) before finishing the package.
Caps recursion at maxBodyFlagDepth = 3 across the four body-emitter helpers:
renderBodyMap, renderBodyVarDecls, renderBodyFlagRegs, renderBodyRequiredChecks.
When the next depth would meet the cap, the object's subtree is skipped
uniformly across all four; deeper fields are reachable via the existing
--stdin flag on POST/PUT/PATCH commands.
The --stdin flag's help text is rewritten when truncation fires so an
operator inspecting --help sees the affordance without reading the issue
tracker. Behavior for shallow specs (<= 2 levels of nesting) is unchanged
across all 20 golden fixtures.
Closes #1240
Refs #1520
* fix(cli): walk flattened body for depth-cap truncation signal
bodyExceedsFlagDepth read endpoint.Body directly, but the four emitters
walk flattenCollidingBodyFields(endpoint.Body). When dot-flattened
identifier collision clears an object's Fields, the emitters treat that
subtree as a JSON-string leaf and never recurse past it, so no depth-cap
truncation actually occurs. The predicate, however, still saw the
original deep structure and returned true, rewriting the --stdin help
text to the truncation-warning variant even though every field was
exposed as a per-field flag.
Walk flattenCollidingBodyFields in the predicate so detection and
emission cannot drift. Adds a regression test for the
collision-flattened deep subtree case.
Refs #1240
---------
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
internal/generator/body_collision_test.go | 115 ++++++++--
internal/generator/body_map_test.go | 237 +++++++++++++++++++++
internal/generator/generator.go | 93 ++++++--
.../generator/templates/command_endpoint.go.tmpl | 4 +
4 files changed, 421 insertions(+), 28 deletions(-)
diff --git a/internal/generator/body_collision_test.go b/internal/generator/body_collision_test.go
index 404bf80d..7f9efcb3 100644
--- a/internal/generator/body_collision_test.go
+++ b/internal/generator/body_collision_test.go
@@ -6,6 +6,7 @@ import (
"go/token"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
@@ -262,6 +263,13 @@ func TestGenerateDeduplicatesNestedTreesCollapsedToSameIdent(t *testing.T) {
// unique identifiers (bodyProjectCustomerName vs
// bodyProjectPostalAddressCustomerName) without falling back on the _N
// suffix.
+//
+// maxBodyFlagDepth truncates the deeper
+// Project.PostalAddress.Customer.name leaf (depth 3); only the shallower
+// Project.Customer.name (depth 2) emits. The dedup behavior is still
+// exercised: were the cap raised, the parent-prefix walker would
+// uniquify both leaves without collision. Truncation is verified via
+// the deeper identifier's absence and the --stdin help text rewrite.
func TestGenerateDeduplicatesConvergentNestedBodyPaths(t *testing.T) {
t.Parallel()
@@ -304,12 +312,12 @@ func TestGenerateDeduplicatesConvergentNestedBodyPaths(t *testing.T) {
"convergent nested paths must produce distinct Go identifiers")
assertNoDuplicates(t, flagBindings,
"convergent nested paths must register distinct cobra flag names")
- require.Len(t, bodyVars, 2,
- "both convergent name leaves must survive as distinct Go identifiers")
+ require.Len(t, bodyVars, 1,
+ "only the shallower project.customer.name survives the depth cap")
assert.Contains(t, bodyVars, "bodyProjectCustomerName",
- "the direct project.customer.name leaf produces this identifier")
- assert.Contains(t, bodyVars, "bodyProjectPostalAddressCustomerName",
- "the project.postalAddress.customer.name leaf produces this identifier via path-prefix joining")
+ "the depth-2 project.customer.name leaf still emits its identifier")
+ assert.NotContains(t, bodyVars, "bodyProjectPostalAddressCustomerName",
+ "the depth-3 project.postalAddress.customer.name leaf is truncated by the cap")
}
// TestGenerateDeduplicatesCyclicRefBodyShape drives the full OpenAPI
@@ -420,17 +428,94 @@ components:
assertNoDuplicates(t, flagBindings,
"cyclic-ref body shape must register distinct cobra flag names")
- // The cycle-cut shape produces three leaves: the direct
- // project.customer.name string and two cycle-cut customer objects
- // at distinct paths.
- require.Len(t, bodyVars, 3,
- "every direct and cycle-cut leaf must survive dedup as a distinct Go identifier")
+ // maxBodyFlagDepth truncates the cycle-cut leaves at depth 3+; only
+ // the direct project.customer.name leaf at depth 2 survives as a
+ // per-field flag. The dedup pass remains the line of defense for any
+ // identifier collisions inside the surviving depth window. Deeper
+ // leaves are reachable via --stdin (the template's help text is
+ // rewritten to advertise the fallback when truncation fires; see
+ // bodyExceedsFlagDepth).
+ require.Len(t, bodyVars, 1,
+ "only the shallow project.customer.name survives the depth cap")
assert.Contains(t, bodyVars, "bodyProjectCustomerName",
- "the direct project.customer.name leaf must emit bodyProjectCustomerName")
- assert.Contains(t, bodyVars, "bodyProjectCustomerLedgerAccountVatTypeCustomer",
- "the ledgerAccount.vatType.customer cycle-cut leaf must emit a unique identifier under its full path")
- assert.Contains(t, bodyVars, "bodyProjectCustomerPostalAddressCustomer",
- "the postalAddress.customer cycle-cut leaf must emit a unique identifier under its full path")
+ "the direct depth-2 project.customer.name leaf still emits bodyProjectCustomerName")
+ assert.NotContains(t, bodyVars, "bodyProjectCustomerLedgerAccountVatTypeCustomer",
+ "the depth-5 ledgerAccount.vatType.customer cycle-cut leaf is truncated by the cap")
+ assert.NotContains(t, bodyVars, "bodyProjectCustomerPostalAddressCustomer",
+ "the depth-4 postalAddress.customer cycle-cut leaf is truncated by the cap")
+
+ // The template's --stdin help text must be rewritten to advertise
+ // the fallback when truncation fires, so an operator inspecting
+ // `--help` sees the affordance without needing to read the issue
+ // tracker.
+ src, err := os.ReadFile(postFile)
+ require.NoError(t, err)
+ assert.Contains(t, string(src),
+ `"Read request body as JSON from stdin (use this for deeply nested fields not exposed as flags)"`,
+ "--stdin help text must reflect truncation when bodyExceedsFlagDepth is true")
+}
+
+// TestGenerateBodyDepthCapPreventsCompilerExplosion guards the
+// regression a spec whose request body recurses past the depth cap
+// must not balloon the emitted Go file. Without the cap, deeply
+// recursive bodies produce 40k+ line files that OOM-kill the Go
+// compiler.
+func TestGenerateBodyDepthCapPreventsCompilerExplosion(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("deep-body-cap")
+ // Build a body with reasonable fan-out (4 fields per level) nested
+ // 6 levels deep. Without the cap, this produces 4^6 = 4096 leaves
+ // and the corresponding flag/var/map blocks. With cap=3, only
+ // 4 + 4 + 4 + 4 = 16 leaves at depths 0..2 plus the depth-2
+ // objects that recurse one level deeper but skip the recursion.
+ build := func(depth int) []spec.Param {
+ var current []spec.Param
+ current = []spec.Param{
+ {Name: "field0", Type: "string"},
+ {Name: "field1", Type: "string"},
+ {Name: "field2", Type: "string"},
+ {Name: "field3", Type: "string"},
+ }
+ for i := depth - 1; i >= 0; i-- {
+ nested := append([]spec.Param{}, current...)
+ current = []spec.Param{
+ {Name: "field0", Type: "string"},
+ {Name: "field1", Type: "string"},
+ {Name: "field2", Type: "string"},
+ {Name: "level" + strconv.Itoa(i), Type: "object", Fields: nested},
+ }
+ }
+ return current
+ }
+
+ apiSpec.Resources["payloads"] = spec.Resource{
+ Description: "Deep payloads",
+ Endpoints: map[string]spec.Endpoint{
+ "create": {
+ Method: "POST",
+ Path: "/payloads",
+ Description: "Submit a deeply nested payload",
+ Body: build(6),
+ },
+ "get": {Method: "GET", Path: "/payloads/{id}", Description: "Get one"},
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "deep-body-cap-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ postFile := filepath.Join(outputDir, "internal", "cli", "payloads_create.go")
+
+ // Exact-count assertion catches off-by-one cap regressions that a
+ // file-size threshold would miss. Fixture has 3 scalar siblings at
+ // each of depths 0, 1, 2 = 9 body-var declarations under cap=3.
+ // Cap=2 would drop to 6; cap=4 would jump to 12; no cap at all
+ // produces 4 + (3 leaves * 5 inner levels) + 4 = 23 leaves down the
+ // single deep chain, and far more in the general fan-out shape.
+ bodyVars, _ := parseBodyDeclarations(t, postFile)
+ require.Len(t, bodyVars, 9,
+ "cap=3 must produce exactly 9 body-var declarations (3 leaves at each of depths 0, 1, 2)")
}
// TestFlattenCollidingBodyFields_NestedPrefixShape covers the Atlassian
diff --git a/internal/generator/body_map_test.go b/internal/generator/body_map_test.go
index 68ff8da1..7407b496 100644
--- a/internal/generator/body_map_test.go
+++ b/internal/generator/body_map_test.go
@@ -1,6 +1,7 @@
package generator
import (
+ "strconv"
"strings"
"testing"
@@ -611,3 +612,239 @@ func TestBodyJSONFallback_RequiredChecks_RequiredBody(t *testing.T) {
t.Errorf("expected body-json in error message, got:%q", got)
}
}
+
+// deepBodyFixture builds a body with one root object whose Fields chain
+// `levels` deep, ending in a string leaf. Each interior object has a
+// scalar sibling so the truncation test can verify which depths emit and
+// which are dropped.
+//
+// body[level0Obj] (depth 0) ->
+// level0Obj.sibling0 (string, depth 1 leaf)
+// level0Obj.level1Obj (depth 1 object) ->
+// level1Obj.sibling1 (string, depth 2 leaf)
+// level1Obj.level2Obj (depth 2 object) ->
+// level2Obj.sibling2 (string, depth 3 leaf, truncated at cap=3)
+// level2Obj.level3Obj (depth 3 object, truncated at cap=3) -> ...
+func deepBodyFixture(levels int) []spec.Param {
+ if levels < 1 {
+ return nil
+ }
+ // Build from the innermost leaf outward.
+ current := []spec.Param{{Name: "leaf", Type: "string"}}
+ for i := levels - 1; i >= 0; i-- {
+ fields := append([]spec.Param{}, current...)
+ fields = append([]spec.Param{{
+ Name: "sibling" + strconv.Itoa(i),
+ Type: "string",
+ }}, fields...)
+ current = []spec.Param{{
+ Name: "level" + strconv.Itoa(i) + "Obj",
+ Type: "object",
+ Fields: fields,
+ }}
+ }
+ return current
+}
+
+// TestBodyMap_DepthCap_TruncatesBelowMax verifies that body-map
+// emission stops recursing into nested objects at maxBodyFlagDepth. A
+// fixture nested 6 levels deep must produce per-field assignments only
+// for depths 0..maxBodyFlagDepth-1; deeper subtrees are silently
+// omitted (the user reaches them via --stdin).
+func TestBodyMap_DepthCap_TruncatesBelowMax(t *testing.T) {
+ t.Parallel()
+ got := bodyMap(deepBodyFixture(6), "\t")
+
+ // The depth-2 sibling and the depth-2 nested map block should both
+ // appear (the cap allows three levels: 0, 1, 2).
+ if !strings.Contains(got, "bodyLevel0ObjLevel1ObjSibling1") {
+ t.Errorf("expected depth-2 sibling leaf to emit, got:\n%s", got)
+ }
+ if !strings.Contains(got, "nestedLevel0ObjLevel1Obj") {
+ t.Errorf("expected depth-2 nested map block, got:\n%s", got)
+ }
+ // The depth-3 sibling, depth-3 object block, and any deeper identifiers
+ // must be absent.
+ if strings.Contains(got, "Sibling2") {
+ t.Errorf("depth-3 sibling must be truncated by the cap, got:\n%s", got)
+ }
+ if strings.Contains(got, "nestedLevel0ObjLevel1ObjLevel2Obj") {
+ t.Errorf("depth-3 nested map block must be truncated, got:\n%s", got)
+ }
+ if strings.Contains(got, "Level3Obj") || strings.Contains(got, "Leaf") {
+ t.Errorf("anything below depth-2 must be omitted, got:\n%s", got)
+ }
+}
+
+// TestBodyVarDecls_DepthCap pins the var-declaration set for a deep body.
+// Only fields reachable within the cap should produce `var bodyX` lines.
+func TestBodyVarDecls_DepthCap(t *testing.T) {
+ t.Parallel()
+ got := bodyVarDecls(spec.Endpoint{Body: deepBodyFixture(6)})
+ for _, want := range []string{
+ "\n\tvar bodyLevel0ObjSibling0 string",
+ "\n\tvar bodyLevel0ObjLevel1ObjSibling1 string",
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("expected within-cap var decl %q, got:\n%s", want, got)
+ }
+ }
+ for _, banned := range []string{
+ "bodyLevel0ObjLevel1ObjLevel2ObjSibling2",
+ "bodyLevel0ObjLevel1ObjLevel2ObjLevel3Obj",
+ } {
+ if strings.Contains(got, banned) {
+ t.Errorf("depth-capped identifier %q must not emit, got:\n%s", banned, got)
+ }
+ }
+}
+
+// TestBodyFlagRegs_DepthCap pins cobra flag registrations for a deep
+// body: only flags reachable within the cap are registered.
+func TestBodyFlagRegs_DepthCap(t *testing.T) {
+ t.Parallel()
+ got := bodyFlagRegs(spec.Endpoint{Body: deepBodyFixture(6)})
+ if !strings.Contains(got, `"level0-obj-level1-obj-sibling1"`) {
+ t.Errorf("expected depth-2 flag registration, got:\n%s", got)
+ }
+ if strings.Contains(got, "sibling2") {
+ t.Errorf("depth-3 flag must be truncated, got:\n%s", got)
+ }
+}
+
+// TestBodyMap_DepthCap_ShallowUnchanged ensures specs that fit inside
+// the cap (2 levels of nesting) emit identical output to today: no
+// truncation, every leaf reachable.
+func TestBodyMap_DepthCap_ShallowUnchanged(t *testing.T) {
+ t.Parallel()
+ got := bodyMap(deepBodyFixture(2), "\t")
+ for _, want := range []string{
+ "bodyLevel0ObjSibling0",
+ "bodyLevel0ObjLevel1ObjSibling1",
+ "bodyLevel0ObjLevel1ObjLeaf",
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("2-level fixture must emit %q with no truncation, got:\n%s", want, got)
+ }
+ }
+}
+
+// TestBodyExceedsFlagDepth_True reports truncation when the body nests
+// past the cap. A 4-level fixture exceeds maxBodyFlagDepth=3.
+func TestBodyExceedsFlagDepth_True(t *testing.T) {
+ t.Parallel()
+ if !bodyExceedsFlagDepth(spec.Endpoint{Body: deepBodyFixture(4)}) {
+ t.Error("4-level body must report truncation under cap=3")
+ }
+}
+
+// TestBodyExceedsFlagDepth_False reports no truncation when the body
+// fits inside the cap.
+func TestBodyExceedsFlagDepth_False(t *testing.T) {
+ t.Parallel()
+ if bodyExceedsFlagDepth(spec.Endpoint{Body: deepBodyFixture(2)}) {
+ t.Error("2-level body must not report truncation under cap=3")
+ }
+ if bodyExceedsFlagDepth(spec.Endpoint{Body: []spec.Param{{Name: "n", Type: "string"}}}) {
+ t.Error("flat body must not report truncation")
+ }
+}
+
+// TestBodyExceedsFlagDepth_BodyJSONFallback never reports truncation
+// for oneOf/anyOf bodies; those route through a single --body-json flag
+// and never reach the per-field emitter.
+func TestBodyExceedsFlagDepth_BodyJSONFallback(t *testing.T) {
+ t.Parallel()
+ if bodyExceedsFlagDepth(spec.Endpoint{BodyJSONFallback: true, Body: deepBodyFixture(6)}) {
+ t.Error("BodyJSONFallback bypasses per-field emission and must not report truncation")
+ }
+}
+
+// TestBodyExceedsFlagDepth_CollisionFlattenedSubtree pins the
+// interaction with flattenCollidingBodyFields. When dot-flattened
+// identifier collision clears an object's Fields, the emitters treat
+// that subtree as a JSON-string leaf and never recurse past it -- no
+// depth-cap truncation occurs. The predicate must read the same
+// flattened tree the emitters render, or the --stdin help text reverts
+// to the truncation-warning variant when every field is in fact a
+// per-field flag.
+func TestBodyExceedsFlagDepth_CollisionFlattenedSubtree(t *testing.T) {
+ t.Parallel()
+ // Top-level scalar 'outerInnerLeaf' collides with the dot-flattened
+ // nested leaf outer.inner.leaf (both camelize to bodyOuterInnerLeaf
+ // at depth 3). flattenCollidingBodyFields clears outer.Fields so the
+ // emitters render outer as a JSON-string leaf at depth 0; no part of
+ // the rendered tree exceeds the cap.
+ body := []spec.Param{
+ {Name: "outerInnerLeaf", Type: "string"},
+ {Name: "outer", Type: "object", Fields: []spec.Param{
+ {Name: "inner", Type: "object", Fields: []spec.Param{
+ {Name: "leaf", Type: "string"},
+ }},
+ }},
+ }
+ if bodyExceedsFlagDepth(spec.Endpoint{Body: body}) {
+ t.Error("collision-flattened subtree must not report truncation; emitters see a flat tree")
+ }
+}
+
+// TestBodyExceedsFlagDepth_Multipart returns false for multipart
+// endpoints regardless of nested-object depth: bodyUsesFlatEmission
+// keeps multipart and form-encoded bodies one-flag-per-top-level-param,
+// so deep nesting never triggers the per-field recursion the cap guards.
+func TestBodyExceedsFlagDepth_Multipart(t *testing.T) {
+ t.Parallel()
+ endpoint := spec.Endpoint{
+ Method: "POST",
+ RequestContentType: "multipart/form-data",
+ Body: deepBodyFixture(6),
+ }
+ if bodyExceedsFlagDepth(endpoint) {
+ t.Error("multipart endpoint must not report truncation; flat emission never recurses")
+ }
+}
+
+// TestBodyMap_DepthCap_Boundary pins the exact depth at which the cap
+// fires. A fixture nested exactly maxBodyFlagDepth levels is the
+// minimal spec that triggers truncation: the depth-2 object's children
+// are at depth 3 and the recursion check (depth+1 >= maxBodyFlagDepth)
+// stops the walk. The depth-1 sibling must still emit; the depth-2
+// sibling and deeper leaves must be absent. A regression that toggled
+// the check to `>` vs `>=` would flip this assertion.
+func TestBodyMap_DepthCap_Boundary(t *testing.T) {
+ t.Parallel()
+ got := bodyMap(deepBodyFixture(maxBodyFlagDepth), "\t")
+ if !strings.Contains(got, "bodyLevel0ObjSibling0") {
+ t.Errorf("depth-1 sibling must emit at the boundary fixture, got:\n%s", got)
+ }
+ if !strings.Contains(got, "bodyLevel0ObjLevel1ObjSibling1") {
+ t.Errorf("depth-2 sibling must emit at the boundary fixture, got:\n%s", got)
+ }
+ if strings.Contains(got, "Sibling2") || strings.Contains(got, "Leaf") {
+ t.Errorf("boundary fixture must not emit depth-3 leaves, got:\n%s", got)
+ }
+}
+
+// TestBodyRequiredChecks_DepthCap omits required-flag checks for fields
+// truncated by the cap. The --stdin path bypasses required checks
+// wholesale, so deep required fields are reachable via stdin only.
+func TestBodyRequiredChecks_DepthCap(t *testing.T) {
+ t.Parallel()
+ // Build a deep fixture where the deepest sibling is required.
+ body := deepBodyFixture(6)
+ // Walk to the depth-3 sibling and mark it required.
+ cursor := &body[0]
+ for range 3 {
+ // cursor.Fields[0] is "siblingN" (string leaf), cursor.Fields[1] is
+ // "level<N+1>Obj" (the nested object). Descend the object branch.
+ cursor = &cursor.Fields[1]
+ }
+ // cursor now points at level3Obj; mark its sibling (depth 4 leaf)
+ // required.
+ cursor.Fields[0].Required = true
+
+ got := bodyRequiredChecks(spec.Endpoint{Body: body}, "\t\t\t")
+ if strings.Contains(got, "sibling3") {
+ t.Errorf("required check on a truncated deep leaf must not emit, got:\n%s", got)
+ }
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 7db4349d..47f03fbf 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -316,6 +316,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"bodyVarDecls": bodyVarDecls,
"bodyFlagRegs": bodyFlagRegs,
"bodyRequiredChecks": bodyRequiredChecks,
+ "bodyExceedsFlagDepth": bodyExceedsFlagDepth,
"multipartBodyMaps": multipartBodyMaps,
"endpointUsesMultipart": endpointUsesMultipart,
"endpointHasQueryFlags": endpointHasQueryFlags,
@@ -3383,6 +3384,21 @@ func endpointNeedsClientLimit(endpoint spec.Endpoint) bool {
return false
}
+// maxBodyFlagDepth caps how many levels of nested-object recursion the
+// body-flag emitters expand into per-field Cobra flags. A Param at
+// depth 0 is a top-level body field; its object children are at depth 1
+// and recurse with depth+1. When the next depth would meet or exceed
+// the cap, the object's subtree is skipped uniformly across renderBodyMap,
+// renderBodyVarDecls, renderBodyFlagRegs, and renderBodyRequiredChecks.
+// The user reaches truncated fields via the existing `--stdin` flag on
+// POST/PUT/PATCH commands, which reads the full JSON body from stdin.
+//
+// Default 3 covers typical CRUD schemas (resource.object.field) without
+// the recursive explosion seen on enterprise/ERP specs that self-reference
+// through 6+ layers and produce 40k-line command files the Go compiler
+// OOMs on.
+const maxBodyFlagDepth = 3
+
// bodyMap renders the per-flag body-building block shared by the
// POST/PUT/PATCH branches in command_endpoint.go.tmpl and the body
// branch in command_promoted.go.tmpl. The four sites generated the
@@ -3395,10 +3411,11 @@ func endpointNeedsClientLimit(endpoint spec.Endpoint) bool {
// 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.
+// map[string]any rather than a single JSON-string flag. Recursion stops
+// at maxBodyFlagDepth; deeper subtrees are only reachable via `--stdin`.
func bodyMap(body []spec.Param, indent string) string {
var b strings.Builder
- renderBodyMap(&b, flattenCollidingBodyFields(body), indent, "body", "", "")
+ renderBodyMap(&b, flattenCollidingBodyFields(body), 0, indent, "body", "", "")
return b.String()
}
@@ -3439,16 +3456,19 @@ func bodyJSONFallbackMap(indent string) string {
return b.String()
}
-func renderBodyMap(b *strings.Builder, body []spec.Param, indent, mapVar, identPrefix, flagPrefix string) {
+func renderBodyMap(b *strings.Builder, body []spec.Param, depth int, indent, mapVar, identPrefix, flagPrefix string) {
for _, p := range body {
id := paramIdent(p)
ident := identPrefix + toCamel(id)
flag := joinFlag(flagPrefix, publicFlagName(p))
if p.Type == "object" && len(p.Fields) > 0 {
+ if depth+1 >= maxBodyFlagDepth {
+ continue
+ }
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)
+ renderBodyMap(b, p.Fields, depth+1, 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)
@@ -3518,7 +3538,7 @@ func bodyVarDecls(endpoint spec.Endpoint) string {
}
return b.String()
}
- renderBodyVarDecls(&b, flattenCollidingBodyFields(endpoint.Body), "")
+ renderBodyVarDecls(&b, flattenCollidingBodyFields(endpoint.Body), 0, "")
return b.String()
}
@@ -3531,11 +3551,14 @@ func bodyUsesFlatEmission(endpoint spec.Endpoint) bool {
return endpointUsesMultipart(endpoint) || endpointUsesForm(endpoint)
}
-func renderBodyVarDecls(b *strings.Builder, body []spec.Param, identPrefix string) {
+func renderBodyVarDecls(b *strings.Builder, body []spec.Param, depth int, identPrefix string) {
for _, p := range body {
ident := identPrefix + toCamel(paramIdent(p))
if p.Type == "object" && len(p.Fields) > 0 {
- renderBodyVarDecls(b, p.Fields, ident)
+ if depth+1 >= maxBodyFlagDepth {
+ continue
+ }
+ renderBodyVarDecls(b, p.Fields, depth+1, ident)
continue
}
fmt.Fprintf(b, "\n\tvar body%s %s", ident, goType(p.Type))
@@ -3560,16 +3583,19 @@ func bodyFlagRegs(endpoint spec.Endpoint) string {
}
return b.String()
}
- renderBodyFlagRegs(&b, flattenCollidingBodyFields(endpoint.Body), "", "", true)
+ renderBodyFlagRegs(&b, flattenCollidingBodyFields(endpoint.Body), 0, "", "", true)
return b.String()
}
-func renderBodyFlagRegs(b *strings.Builder, body []spec.Param, identPrefix, flagPrefix string, topLevel bool) {
+func renderBodyFlagRegs(b *strings.Builder, body []spec.Param, depth int, identPrefix, flagPrefix string, topLevel bool) {
for _, p := range body {
if p.Type == "object" && len(p.Fields) > 0 {
+ if depth+1 >= maxBodyFlagDepth {
+ continue
+ }
ident := identPrefix + toCamel(paramIdent(p))
flag := joinFlag(flagPrefix, publicFlagName(p))
- renderBodyFlagRegs(b, p.Fields, ident, flag, false)
+ renderBodyFlagRegs(b, p.Fields, depth+1, ident, flag, false)
continue
}
renderFlatBodyFlagReg(b, p, identPrefix, flagPrefix, topLevel)
@@ -3622,21 +3648,62 @@ func bodyRequiredChecks(endpoint spec.Endpoint, indent string) string {
}
return b.String()
}
- renderBodyRequiredChecks(&b, flattenCollidingBodyFields(endpoint.Body), indent, "", true)
+ renderBodyRequiredChecks(&b, flattenCollidingBodyFields(endpoint.Body), 0, indent, "", true)
return b.String()
}
-func renderBodyRequiredChecks(b *strings.Builder, body []spec.Param, indent, flagPrefix string, topLevel bool) {
+func renderBodyRequiredChecks(b *strings.Builder, body []spec.Param, depth int, indent, flagPrefix string, topLevel bool) {
for _, p := range body {
if p.Type == "object" && len(p.Fields) > 0 {
+ if depth+1 >= maxBodyFlagDepth {
+ continue
+ }
flag := joinFlag(flagPrefix, publicFlagName(p))
- renderBodyRequiredChecks(b, p.Fields, indent, flag, false)
+ renderBodyRequiredChecks(b, p.Fields, depth+1, indent, flag, false)
continue
}
renderFlatBodyRequiredCheck(b, p, indent, flagPrefix, topLevel)
}
}
+// bodyExceedsFlagDepth reports whether emitting per-field body flags for
+// the endpoint would have truncated any nested-object subtree under
+// maxBodyFlagDepth. Multipart/form endpoints stay flat and never
+// truncate; BodyJSONFallback endpoints route through a single
+// --body-json flag and never reach the per-field path.
+//
+// The walk uses flattenCollidingBodyFields because that is what the
+// emitters render. Collision-flattening clears `Fields` on an object
+// whose dot-flattened subtree would clash with a sibling identifier,
+// turning it into a JSON-string leaf the user passes as a single flag.
+// Walking the raw body would falsely report truncation in that case
+// and rewrite the --stdin help text even when every field is exposed.
+func bodyExceedsFlagDepth(endpoint spec.Endpoint) bool {
+ if endpoint.BodyJSONFallback || bodyUsesFlatEmission(endpoint) {
+ return false
+ }
+ return walkBodyExceedsDepth(flattenCollidingBodyFields(endpoint.Body), 0)
+}
+
+// walkBodyExceedsDepth returns true as soon as any nested-object subtree
+// at depth >= maxBodyFlagDepth-1 is found. The walk is bounded by the
+// same depth check the emitters use, so a Param graph that
+// intentionally self-references (cyclic spec) does not loop here.
+func walkBodyExceedsDepth(body []spec.Param, depth int) bool {
+ for _, p := range body {
+ if p.Type != "object" || len(p.Fields) == 0 {
+ continue
+ }
+ if depth+1 >= maxBodyFlagDepth {
+ return true
+ }
+ if walkBodyExceedsDepth(p.Fields, depth+1) {
+ return true
+ }
+ }
+ return false
+}
+
func renderFlatBodyRequiredCheck(b *strings.Builder, p spec.Param, indent, flagPrefix string, topLevel bool) {
if !p.Required || p.Default != nil {
return
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index ab24938b..8ed96435 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -650,8 +650,12 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
{{- end}}
{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart) (not $isForm)}}
+{{- if bodyExceedsFlagDepth .Endpoint}}
+ cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin (use this for deeply nested fields not exposed as flags)")
+{{- else}}
cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin")
{{- end}}
+{{- end}}
{{- if .IsAsync}}
cmd.Flags().BoolVar(&flagWait, "wait", false, "Block until the submitted job reaches a terminal status")
cmd.Flags().DurationVar(&flagWaitTimeout, "wait-timeout", 10*time.Minute, "Maximum duration to wait when --wait is set (0 = no timeout)")
← 0c3c8bcc fix(cli): detect integer page paginator and advance numerica
·
back to Cli Printing Press
·
fix(skills): require codex completion marker before treating 99637941 →