← back to Cli Printing Press
fix(cli): descend --select into list-envelope responses (#1379)
3bcb9d9009596fa273e1fa88da80fa06176fbcc6 · 2026-05-14 01:34:53 -0700 · Trevin Chow
The runtime `filterFieldsRec` helper emitted into every printed CLI
matched selector heads only against an object's top-level keys, so
`--select id,name` against `{"projects":[...]}` (orgo), `{"data":[...]}`
(stripe), `{"total_count":N,"items":[...]}` (github), or
`{"object":"list","results":[...]}` (notion) returned `{}` because
`id` and `name` lived inside the wrapped array, not on the envelope.
When no top-level key matches and at least one non-null array sibling
exists, the helper now treats the object as a list envelope: it applies
the selector inside each array and preserves non-array siblings
verbatim so envelope metadata (counts, pagination cursors) stays
visible. The `arr != nil` check rejects JSON null, which
`json.Unmarshal` otherwise accepts into a `[]json.RawMessage` as a
nil slice and would coerce a null pagination cursor into `[]`.
Test coverage lives in two places:
* `root_test.go.tmpl` ships table-driven coverage with every printed
CLI's `internal/cli/root_test.go`, exercising bare arrays, direct
objects, single/multi-array envelopes, metadata-sibling envelopes
(stripe/github shapes), null-cursor preservation, the
envelope-key-as-selector case (suppresses descent), dotted-path
descent, the flat-no-match `{}` invariant, and the
intentionally-not-descended nested-object case.
* `internal/generator/filter_fields_envelope_test.go` runs the
emitted helper inside cli-printing-press's own CI by generating a
CLI to a temp dir, writing a fixture `_test.go` alongside the
helper, and invoking `go test` on the generated module — modeled
on `TestRootFlagsPrintJSONHonorsOutputFlags`.
Closes #1143
Files touched
A internal/generator/filter_fields_envelope_test.goM internal/generator/templates/helpers.go.tmplM internal/generator/templates/root_test.go.tmplM testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/root_test.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root_test.go
Diff
commit 3bcb9d9009596fa273e1fa88da80fa06176fbcc6
Author: Trevin Chow <trevin@trevinchow.com>
Date: Thu May 14 01:34:53 2026 -0700
fix(cli): descend --select into list-envelope responses (#1379)
The runtime `filterFieldsRec` helper emitted into every printed CLI
matched selector heads only against an object's top-level keys, so
`--select id,name` against `{"projects":[...]}` (orgo), `{"data":[...]}`
(stripe), `{"total_count":N,"items":[...]}` (github), or
`{"object":"list","results":[...]}` (notion) returned `{}` because
`id` and `name` lived inside the wrapped array, not on the envelope.
When no top-level key matches and at least one non-null array sibling
exists, the helper now treats the object as a list envelope: it applies
the selector inside each array and preserves non-array siblings
verbatim so envelope metadata (counts, pagination cursors) stays
visible. The `arr != nil` check rejects JSON null, which
`json.Unmarshal` otherwise accepts into a `[]json.RawMessage` as a
nil slice and would coerce a null pagination cursor into `[]`.
Test coverage lives in two places:
* `root_test.go.tmpl` ships table-driven coverage with every printed
CLI's `internal/cli/root_test.go`, exercising bare arrays, direct
objects, single/multi-array envelopes, metadata-sibling envelopes
(stripe/github shapes), null-cursor preservation, the
envelope-key-as-selector case (suppresses descent), dotted-path
descent, the flat-no-match `{}` invariant, and the
intentionally-not-descended nested-object case.
* `internal/generator/filter_fields_envelope_test.go` runs the
emitted helper inside cli-printing-press's own CI by generating a
CLI to a temp dir, writing a fixture `_test.go` alongside the
helper, and invoking `go test` on the generated module — modeled
on `TestRootFlagsPrintJSONHonorsOutputFlags`.
Closes #1143
---
internal/generator/filter_fields_envelope_test.go | 108 +++++++++++++++++
internal/generator/templates/helpers.go.tmpl | 30 +++++
internal/generator/templates/root_test.go.tmpl | 128 +++++++++++++++++++++
.../embedded-paged-api/internal/cli/helpers.go | 30 +++++
.../internal/cli/helpers.go | 30 +++++
.../internal/cli/root_test.go | 128 +++++++++++++++++++++
.../printing-press-golden/internal/cli/helpers.go | 30 +++++
.../internal/cli/root_test.go | 128 +++++++++++++++++++++
8 files changed, 612 insertions(+)
diff --git a/internal/generator/filter_fields_envelope_test.go b/internal/generator/filter_fields_envelope_test.go
new file mode 100644
index 00000000..5efa35e6
--- /dev/null
+++ b/internal/generator/filter_fields_envelope_test.go
@@ -0,0 +1,108 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestFilterFieldsEnvelopeDescent_EmittedHelper guards the runtime behavior
+// of filterFields against list-envelope responses inside the cli-printing-press
+// repo's own test suite. The function is emitted into every printed CLI's
+// internal/cli/helpers.go from helpers.go.tmpl. Without this gate, regressions
+// in the envelope-descent fallback only surface when a user runs `go test ./...`
+// inside a generated CLI, which slows the feedback loop and risks shipping a
+// broken --select to every CLI built from a future bad commit.
+//
+// The test follows the TestRootFlagsPrintJSONHonorsOutputFlags pattern: it
+// generates a CLI to a temp dir, writes a fixture _test.go alongside the
+// emitted helpers, then runs `go test` on the generated module.
+func TestFilterFieldsEnvelopeDescent_EmittedHelper(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("envelope-descent")
+ outputDir := filepath.Join(t.TempDir(), "envelope-descent-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ testPath := filepath.Join(outputDir, "internal", "cli", "filter_fields_envelope_test.go")
+ require.NoError(t, os.WriteFile(testPath, []byte(`package cli
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+// TestFilterFieldsEnvelopeDescent covers the four shapes printed CLIs see in
+// practice. The envelope cases pin the regression where wrapper-key + array
+// responses returned `+"`{}`"+` because the selector heads matched the inner
+// record fields, not the wrapper key.
+func TestFilterFieldsEnvelopeDescent(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ fields string
+ want string
+ }{
+ {
+ "bare array element-wise",
+ `+"`"+`[{"id":"a","name":"x","other":"y"}]`+"`"+`,
+ "id,name",
+ `+"`"+`[{"id":"a","name":"x"}]`+"`"+`,
+ },
+ {
+ "envelope single array sibling",
+ `+"`"+`{"projects":[{"id":"a","name":"x","other":"y"}]}`+"`"+`,
+ "id,name",
+ `+"`"+`{"projects":[{"id":"a","name":"x"}]}`+"`"+`,
+ },
+ {
+ "envelope with metadata sibling preserves count",
+ `+"`"+`{"total_count":2,"items":[{"id":"a","other":"y"}]}`+"`"+`,
+ "id",
+ `+"`"+`{"items":[{"id":"a"}],"total_count":2}`+"`"+`,
+ },
+ {
+ "envelope preserves null pagination cursor verbatim",
+ `+"`"+`{"items":[{"id":"a"}],"next_cursor":null}`+"`"+`,
+ "id",
+ `+"`"+`{"items":[{"id":"a"}],"next_cursor":null}`+"`"+`,
+ },
+ {
+ "flat object no match returns empty",
+ `+"`"+`{"a":1,"b":2}`+"`"+`,
+ "c",
+ `+"`"+`{}`+"`"+`,
+ },
+ {
+ "selector matches envelope key suppresses descent",
+ `+"`"+`{"projects":[{"id":"a","other":"y"}]}`+"`"+`,
+ "projects",
+ `+"`"+`{"projects":[{"id":"a","other":"y"}]}`+"`"+`,
+ },
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ got := filterFields(json.RawMessage(tc.input), tc.fields)
+ var gotV, wantV interface{}
+ if err := json.Unmarshal(got, &gotV); err != nil {
+ t.Fatalf("invalid json output: %v (raw=%s)", err, string(got))
+ }
+ if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+ t.Fatalf("invalid want json: %v (raw=%s)", err, tc.want)
+ }
+ gotBytes, _ := json.Marshal(gotV)
+ wantBytes, _ := json.Marshal(wantV)
+ if string(gotBytes) != string(wantBytes) {
+ t.Errorf("filterFields(%q, %q) = %s, want %s",
+ tc.input, tc.fields, string(gotBytes), string(wantBytes))
+ }
+ })
+ }
+}
+`), 0o644))
+
+ runGoCommand(t, outputDir, "test", "./internal/cli", "-run", "TestFilterFieldsEnvelopeDescent", "-count=1")
+}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 65074cf5..abdca004 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -993,11 +993,13 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
}
}
filtered := map[string]json.RawMessage{}
+ matchedAny := false
for k, v := range obj {
matched := matchSelectSegment(k, keepWhole, subPaths)
if matched == "" {
continue
}
+ matchedAny = true
if keepWhole[matched] {
filtered[k] = v
continue
@@ -1006,6 +1008,34 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
filtered[k] = filterFieldsRec(v, subs)
}
}
+ // Envelope fallback: when no top-level keys matched but at least one
+ // sibling is a non-null array, treat the object as a list envelope
+ // (`{"items":[...]}`, `{"data":[...]}`, `{"total_count":N,"items":[...]}`)
+ // and apply the selector inside the array(s). Non-array siblings pass
+ // through verbatim so envelope metadata (counts, null pagination
+ // cursors) stays visible. The foundArray guard preserves the prior
+ // empty-object result for flat objects where no key matches and no
+ // array exists. The `arr != nil` check rejects JSON null, which
+ // json.Unmarshal otherwise accepts into a []json.RawMessage as a
+ // nil slice and would coerce to `[]`.
+ if !matchedAny {
+ pending := map[string]json.RawMessage{}
+ foundArray := false
+ for k, v := range obj {
+ var arr []json.RawMessage
+ if json.Unmarshal(v, &arr) == nil && arr != nil {
+ foundArray = true
+ pending[k] = filterFieldsRec(v, paths)
+ } else {
+ pending[k] = v
+ }
+ }
+ if foundArray {
+ for k, v := range pending {
+ filtered[k] = v
+ }
+ }
+ }
result, _ := json.Marshal(filtered)
return result
}
diff --git a/internal/generator/templates/root_test.go.tmpl b/internal/generator/templates/root_test.go.tmpl
index c40194da..b1cd048e 100644
--- a/internal/generator/templates/root_test.go.tmpl
+++ b/internal/generator/templates/root_test.go.tmpl
@@ -4,6 +4,7 @@
package cli
import (
+ "encoding/json"
"errors"
"fmt"
"testing"
@@ -75,3 +76,130 @@ func TestExitCode_UsageError_WrappedAsCode2(t *testing.T) {
t.Errorf("ExitCode(usageErr(...)) = %d, want 2 (POSIX usage convention)", got)
}
}
+
+// TestFilterFields covers --select projection against the four payload
+// shapes printed CLIs see in practice: bare arrays, direct objects,
+// list envelopes (Stripe/GitHub/Notion-style wrapper + array), and
+// flat objects. The envelope cases guard against a regression where
+// wrapper-key + array responses returned `{}` because the selector
+// heads matched the inner record fields, not the wrapper key.
+func TestFilterFields(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ input string
+ fields string
+ want string
+ }{
+ {
+ name: "bare array element-wise",
+ input: `[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]`,
+ fields: "id,name",
+ want: `[{"id":"a","name":"x"},{"id":"b","name":"z"}]`,
+ },
+ {
+ name: "direct object top-level match",
+ input: `{"id":"a","name":"b","other":"c"}`,
+ fields: "id,name",
+ want: `{"id":"a","name":"b"}`,
+ },
+ {
+ name: "envelope single array sibling (orgo {projects:[...]})",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "id,name",
+ want: `{"projects":[{"id":"a","name":"x"}]}`,
+ },
+ {
+ name: "envelope with metadata sibling (github {total_count,items:[...]})",
+ input: `{"total_count":2,"items":[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]}`,
+ fields: "id,name",
+ want: `{"items":[{"id":"a","name":"x"},{"id":"b","name":"z"}],"total_count":2}`,
+ },
+ {
+ name: "envelope with metadata sibling (stripe {object,data:[...]})",
+ input: `{"object":"list","data":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "id,name",
+ want: `{"data":[{"id":"a","name":"x"}],"object":"list"}`,
+ },
+ {
+ name: "selector matches envelope key (no descent into array)",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "projects",
+ want: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ },
+ {
+ name: "dotted-path through envelope key still descends",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "projects.id",
+ want: `{"projects":[{"id":"a"}]}`,
+ },
+ {
+ name: "flat object no match returns empty (no array fallback)",
+ input: `{"a":1,"b":2}`,
+ fields: "c",
+ want: `{}`,
+ },
+ {
+ // Null pagination cursors are common envelope metadata.
+ // json.Unmarshal accepts JSON null into []json.RawMessage as
+ // a nil slice without error, so the array check must reject
+ // nil explicitly or null siblings would be coerced to `[]`.
+ name: "envelope preserves null sibling verbatim",
+ input: `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+ fields: "id,name",
+ want: `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+ },
+ {
+ // Without a real array sibling the envelope fallback does not
+ // fire, so a flat object whose only "extra" key is null still
+ // returns {} for a non-matching selector.
+ name: "flat object with null sibling no match returns empty",
+ input: `{"a":1,"b":null}`,
+ fields: "c",
+ want: `{}`,
+ },
+ {
+ // Multiple array siblings at the same level each receive the
+ // selector independently. Documents that the projection fans
+ // out across every array, not just the first one found.
+ name: "envelope with two array siblings filters both",
+ input: `{"events":[{"id":"e1","other":"x"}],"speakers":[{"id":"s1","other":"y"}]}`,
+ fields: "id",
+ want: `{"events":[{"id":"e1"}],"speakers":[{"id":"s1"}]}`,
+ },
+ {
+ // Envelope fallback is intentionally one level deep. A nested
+ // object envelope like {"data":{"items":[...]}} surfaces no
+ // array at the outer level, so the fallback does not fire and
+ // the result is the empty-object that flat-no-match would
+ // produce. Pins the boundary so a future deeper-walk change
+ // is an explicit decision, not an accident.
+ name: "nested object envelope returns empty (one-level only)",
+ input: `{"data":{"items":[{"id":"a","other":"y"}]}}`,
+ fields: "id",
+ want: `{}`,
+ },
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := filterFields(json.RawMessage(tc.input), tc.fields)
+ // Normalize both sides through json.Unmarshal+Marshal so
+ // map-iteration order does not produce false negatives.
+ var gotV, wantV interface{}
+ if err := json.Unmarshal(got, &gotV); err != nil {
+ t.Fatalf("got is invalid json: %v (raw=%s)", err, string(got))
+ }
+ if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+ t.Fatalf("want is invalid json: %v (raw=%s)", err, tc.want)
+ }
+ gotBytes, _ := json.Marshal(gotV)
+ wantBytes, _ := json.Marshal(wantV)
+ if string(gotBytes) != string(wantBytes) {
+ t.Errorf("filterFields(%q, %q) = %s, want %s",
+ tc.input, tc.fields, string(gotBytes), string(wantBytes))
+ }
+ })
+ }
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
index 73b178a8..662fafb7 100644
--- a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
@@ -792,11 +792,13 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
}
}
filtered := map[string]json.RawMessage{}
+ matchedAny := false
for k, v := range obj {
matched := matchSelectSegment(k, keepWhole, subPaths)
if matched == "" {
continue
}
+ matchedAny = true
if keepWhole[matched] {
filtered[k] = v
continue
@@ -805,6 +807,34 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
filtered[k] = filterFieldsRec(v, subs)
}
}
+ // Envelope fallback: when no top-level keys matched but at least one
+ // sibling is a non-null array, treat the object as a list envelope
+ // (`{"items":[...]}`, `{"data":[...]}`, `{"total_count":N,"items":[...]}`)
+ // and apply the selector inside the array(s). Non-array siblings pass
+ // through verbatim so envelope metadata (counts, null pagination
+ // cursors) stays visible. The foundArray guard preserves the prior
+ // empty-object result for flat objects where no key matches and no
+ // array exists. The `arr != nil` check rejects JSON null, which
+ // json.Unmarshal otherwise accepts into a []json.RawMessage as a
+ // nil slice and would coerce to `[]`.
+ if !matchedAny {
+ pending := map[string]json.RawMessage{}
+ foundArray := false
+ for k, v := range obj {
+ var arr []json.RawMessage
+ if json.Unmarshal(v, &arr) == nil && arr != nil {
+ foundArray = true
+ pending[k] = filterFieldsRec(v, paths)
+ } else {
+ pending[k] = v
+ }
+ }
+ if foundArray {
+ for k, v := range pending {
+ filtered[k] = v
+ }
+ }
+ }
result, _ := json.Marshal(filtered)
return result
}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
index 33ef4ad3..5826f23a 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -655,11 +655,13 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
}
}
filtered := map[string]json.RawMessage{}
+ matchedAny := false
for k, v := range obj {
matched := matchSelectSegment(k, keepWhole, subPaths)
if matched == "" {
continue
}
+ matchedAny = true
if keepWhole[matched] {
filtered[k] = v
continue
@@ -668,6 +670,34 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
filtered[k] = filterFieldsRec(v, subs)
}
}
+ // Envelope fallback: when no top-level keys matched but at least one
+ // sibling is a non-null array, treat the object as a list envelope
+ // (`{"items":[...]}`, `{"data":[...]}`, `{"total_count":N,"items":[...]}`)
+ // and apply the selector inside the array(s). Non-array siblings pass
+ // through verbatim so envelope metadata (counts, null pagination
+ // cursors) stays visible. The foundArray guard preserves the prior
+ // empty-object result for flat objects where no key matches and no
+ // array exists. The `arr != nil` check rejects JSON null, which
+ // json.Unmarshal otherwise accepts into a []json.RawMessage as a
+ // nil slice and would coerce to `[]`.
+ if !matchedAny {
+ pending := map[string]json.RawMessage{}
+ foundArray := false
+ for k, v := range obj {
+ var arr []json.RawMessage
+ if json.Unmarshal(v, &arr) == nil && arr != nil {
+ foundArray = true
+ pending[k] = filterFieldsRec(v, paths)
+ } else {
+ pending[k] = v
+ }
+ }
+ if foundArray {
+ for k, v := range pending {
+ filtered[k] = v
+ }
+ }
+ }
result, _ := json.Marshal(filtered)
return result
}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/root_test.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/root_test.go
index d04efeed..e5336e44 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/root_test.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/root_test.go
@@ -4,6 +4,7 @@
package cli
import (
+ "encoding/json"
"errors"
"fmt"
"testing"
@@ -75,3 +76,130 @@ func TestExitCode_UsageError_WrappedAsCode2(t *testing.T) {
t.Errorf("ExitCode(usageErr(...)) = %d, want 2 (POSIX usage convention)", got)
}
}
+
+// TestFilterFields covers --select projection against the four payload
+// shapes printed CLIs see in practice: bare arrays, direct objects,
+// list envelopes (Stripe/GitHub/Notion-style wrapper + array), and
+// flat objects. The envelope cases guard against a regression where
+// wrapper-key + array responses returned `{}` because the selector
+// heads matched the inner record fields, not the wrapper key.
+func TestFilterFields(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ input string
+ fields string
+ want string
+ }{
+ {
+ name: "bare array element-wise",
+ input: `[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]`,
+ fields: "id,name",
+ want: `[{"id":"a","name":"x"},{"id":"b","name":"z"}]`,
+ },
+ {
+ name: "direct object top-level match",
+ input: `{"id":"a","name":"b","other":"c"}`,
+ fields: "id,name",
+ want: `{"id":"a","name":"b"}`,
+ },
+ {
+ name: "envelope single array sibling (orgo {projects:[...]})",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "id,name",
+ want: `{"projects":[{"id":"a","name":"x"}]}`,
+ },
+ {
+ name: "envelope with metadata sibling (github {total_count,items:[...]})",
+ input: `{"total_count":2,"items":[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]}`,
+ fields: "id,name",
+ want: `{"items":[{"id":"a","name":"x"},{"id":"b","name":"z"}],"total_count":2}`,
+ },
+ {
+ name: "envelope with metadata sibling (stripe {object,data:[...]})",
+ input: `{"object":"list","data":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "id,name",
+ want: `{"data":[{"id":"a","name":"x"}],"object":"list"}`,
+ },
+ {
+ name: "selector matches envelope key (no descent into array)",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "projects",
+ want: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ },
+ {
+ name: "dotted-path through envelope key still descends",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "projects.id",
+ want: `{"projects":[{"id":"a"}]}`,
+ },
+ {
+ name: "flat object no match returns empty (no array fallback)",
+ input: `{"a":1,"b":2}`,
+ fields: "c",
+ want: `{}`,
+ },
+ {
+ // Null pagination cursors are common envelope metadata.
+ // json.Unmarshal accepts JSON null into []json.RawMessage as
+ // a nil slice without error, so the array check must reject
+ // nil explicitly or null siblings would be coerced to `[]`.
+ name: "envelope preserves null sibling verbatim",
+ input: `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+ fields: "id,name",
+ want: `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+ },
+ {
+ // Without a real array sibling the envelope fallback does not
+ // fire, so a flat object whose only "extra" key is null still
+ // returns {} for a non-matching selector.
+ name: "flat object with null sibling no match returns empty",
+ input: `{"a":1,"b":null}`,
+ fields: "c",
+ want: `{}`,
+ },
+ {
+ // Multiple array siblings at the same level each receive the
+ // selector independently. Documents that the projection fans
+ // out across every array, not just the first one found.
+ name: "envelope with two array siblings filters both",
+ input: `{"events":[{"id":"e1","other":"x"}],"speakers":[{"id":"s1","other":"y"}]}`,
+ fields: "id",
+ want: `{"events":[{"id":"e1"}],"speakers":[{"id":"s1"}]}`,
+ },
+ {
+ // Envelope fallback is intentionally one level deep. A nested
+ // object envelope like {"data":{"items":[...]}} surfaces no
+ // array at the outer level, so the fallback does not fire and
+ // the result is the empty-object that flat-no-match would
+ // produce. Pins the boundary so a future deeper-walk change
+ // is an explicit decision, not an accident.
+ name: "nested object envelope returns empty (one-level only)",
+ input: `{"data":{"items":[{"id":"a","other":"y"}]}}`,
+ fields: "id",
+ want: `{}`,
+ },
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := filterFields(json.RawMessage(tc.input), tc.fields)
+ // Normalize both sides through json.Unmarshal+Marshal so
+ // map-iteration order does not produce false negatives.
+ var gotV, wantV interface{}
+ if err := json.Unmarshal(got, &gotV); err != nil {
+ t.Fatalf("got is invalid json: %v (raw=%s)", err, string(got))
+ }
+ if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+ t.Fatalf("want is invalid json: %v (raw=%s)", err, tc.want)
+ }
+ gotBytes, _ := json.Marshal(gotV)
+ wantBytes, _ := json.Marshal(wantV)
+ if string(gotBytes) != string(wantBytes) {
+ t.Errorf("filterFields(%q, %q) = %s, want %s",
+ tc.input, tc.fields, string(gotBytes), string(wantBytes))
+ }
+ })
+ }
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index 3dcd6f7b..c2b796cd 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -662,11 +662,13 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
}
}
filtered := map[string]json.RawMessage{}
+ matchedAny := false
for k, v := range obj {
matched := matchSelectSegment(k, keepWhole, subPaths)
if matched == "" {
continue
}
+ matchedAny = true
if keepWhole[matched] {
filtered[k] = v
continue
@@ -675,6 +677,34 @@ func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
filtered[k] = filterFieldsRec(v, subs)
}
}
+ // Envelope fallback: when no top-level keys matched but at least one
+ // sibling is a non-null array, treat the object as a list envelope
+ // (`{"items":[...]}`, `{"data":[...]}`, `{"total_count":N,"items":[...]}`)
+ // and apply the selector inside the array(s). Non-array siblings pass
+ // through verbatim so envelope metadata (counts, null pagination
+ // cursors) stays visible. The foundArray guard preserves the prior
+ // empty-object result for flat objects where no key matches and no
+ // array exists. The `arr != nil` check rejects JSON null, which
+ // json.Unmarshal otherwise accepts into a []json.RawMessage as a
+ // nil slice and would coerce to `[]`.
+ if !matchedAny {
+ pending := map[string]json.RawMessage{}
+ foundArray := false
+ for k, v := range obj {
+ var arr []json.RawMessage
+ if json.Unmarshal(v, &arr) == nil && arr != nil {
+ foundArray = true
+ pending[k] = filterFieldsRec(v, paths)
+ } else {
+ pending[k] = v
+ }
+ }
+ if foundArray {
+ for k, v := range pending {
+ filtered[k] = v
+ }
+ }
+ }
result, _ := json.Marshal(filtered)
return result
}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root_test.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root_test.go
index d04efeed..e5336e44 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root_test.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root_test.go
@@ -4,6 +4,7 @@
package cli
import (
+ "encoding/json"
"errors"
"fmt"
"testing"
@@ -75,3 +76,130 @@ func TestExitCode_UsageError_WrappedAsCode2(t *testing.T) {
t.Errorf("ExitCode(usageErr(...)) = %d, want 2 (POSIX usage convention)", got)
}
}
+
+// TestFilterFields covers --select projection against the four payload
+// shapes printed CLIs see in practice: bare arrays, direct objects,
+// list envelopes (Stripe/GitHub/Notion-style wrapper + array), and
+// flat objects. The envelope cases guard against a regression where
+// wrapper-key + array responses returned `{}` because the selector
+// heads matched the inner record fields, not the wrapper key.
+func TestFilterFields(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ input string
+ fields string
+ want string
+ }{
+ {
+ name: "bare array element-wise",
+ input: `[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]`,
+ fields: "id,name",
+ want: `[{"id":"a","name":"x"},{"id":"b","name":"z"}]`,
+ },
+ {
+ name: "direct object top-level match",
+ input: `{"id":"a","name":"b","other":"c"}`,
+ fields: "id,name",
+ want: `{"id":"a","name":"b"}`,
+ },
+ {
+ name: "envelope single array sibling (orgo {projects:[...]})",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "id,name",
+ want: `{"projects":[{"id":"a","name":"x"}]}`,
+ },
+ {
+ name: "envelope with metadata sibling (github {total_count,items:[...]})",
+ input: `{"total_count":2,"items":[{"id":"a","name":"x","other":"y"},{"id":"b","name":"z","other":"w"}]}`,
+ fields: "id,name",
+ want: `{"items":[{"id":"a","name":"x"},{"id":"b","name":"z"}],"total_count":2}`,
+ },
+ {
+ name: "envelope with metadata sibling (stripe {object,data:[...]})",
+ input: `{"object":"list","data":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "id,name",
+ want: `{"data":[{"id":"a","name":"x"}],"object":"list"}`,
+ },
+ {
+ name: "selector matches envelope key (no descent into array)",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "projects",
+ want: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ },
+ {
+ name: "dotted-path through envelope key still descends",
+ input: `{"projects":[{"id":"a","name":"x","other":"y"}]}`,
+ fields: "projects.id",
+ want: `{"projects":[{"id":"a"}]}`,
+ },
+ {
+ name: "flat object no match returns empty (no array fallback)",
+ input: `{"a":1,"b":2}`,
+ fields: "c",
+ want: `{}`,
+ },
+ {
+ // Null pagination cursors are common envelope metadata.
+ // json.Unmarshal accepts JSON null into []json.RawMessage as
+ // a nil slice without error, so the array check must reject
+ // nil explicitly or null siblings would be coerced to `[]`.
+ name: "envelope preserves null sibling verbatim",
+ input: `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+ fields: "id,name",
+ want: `{"items":[{"id":"a","name":"x"}],"next_cursor":null}`,
+ },
+ {
+ // Without a real array sibling the envelope fallback does not
+ // fire, so a flat object whose only "extra" key is null still
+ // returns {} for a non-matching selector.
+ name: "flat object with null sibling no match returns empty",
+ input: `{"a":1,"b":null}`,
+ fields: "c",
+ want: `{}`,
+ },
+ {
+ // Multiple array siblings at the same level each receive the
+ // selector independently. Documents that the projection fans
+ // out across every array, not just the first one found.
+ name: "envelope with two array siblings filters both",
+ input: `{"events":[{"id":"e1","other":"x"}],"speakers":[{"id":"s1","other":"y"}]}`,
+ fields: "id",
+ want: `{"events":[{"id":"e1"}],"speakers":[{"id":"s1"}]}`,
+ },
+ {
+ // Envelope fallback is intentionally one level deep. A nested
+ // object envelope like {"data":{"items":[...]}} surfaces no
+ // array at the outer level, so the fallback does not fire and
+ // the result is the empty-object that flat-no-match would
+ // produce. Pins the boundary so a future deeper-walk change
+ // is an explicit decision, not an accident.
+ name: "nested object envelope returns empty (one-level only)",
+ input: `{"data":{"items":[{"id":"a","other":"y"}]}}`,
+ fields: "id",
+ want: `{}`,
+ },
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := filterFields(json.RawMessage(tc.input), tc.fields)
+ // Normalize both sides through json.Unmarshal+Marshal so
+ // map-iteration order does not produce false negatives.
+ var gotV, wantV interface{}
+ if err := json.Unmarshal(got, &gotV); err != nil {
+ t.Fatalf("got is invalid json: %v (raw=%s)", err, string(got))
+ }
+ if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+ t.Fatalf("want is invalid json: %v (raw=%s)", err, tc.want)
+ }
+ gotBytes, _ := json.Marshal(gotV)
+ wantBytes, _ := json.Marshal(wantV)
+ if string(gotBytes) != string(wantBytes) {
+ t.Errorf("filterFields(%q, %q) = %s, want %s",
+ tc.input, tc.fields, string(gotBytes), string(wantBytes))
+ }
+ })
+ }
+}
← 295fd03f fix(cli): drop shell line comments + skip happy_path on miss
·
back to Cli Printing Press
·
fix(generator): support single-env basic auth credentials (# ac83f0fd →