Skip to content

Commit 90a1347

Browse files
committed
feat(cli): add --json flag with field selection for structured output
Add Exporter interface and AddJSONFlags helper that gives any command a --json flag with comma-separated field selection, validation, and shell completion. - Fields extracted via json struct tags with case-insensitive fallback - Exportable interface for custom field handling - StructExportData helper for simple structs - Pretty-printed JSON on TTY, compact when piped - Rejects unknown fields with helpful error listing available fields
1 parent 866c776 commit 90a1347

5 files changed

Lines changed: 480 additions & 1 deletion

File tree

MIGRATION.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,38 @@ The following exports are removed — their functionality is now internal to `cl
254254
| `cli.FlagError` (type) | Unexported; flag errors are handled internally by `Execute` |
255255
| `commander.IsCommandErr(err)` | Removed; `Execute` detects and handles flag/command errors |
256256

257+
## JSON output
258+
259+
Commands can offer `--json` with field selection via `cli.AddJSONFlags`:
260+
261+
```go
262+
var exporter cli.Exporter
263+
264+
listCmd := &cobra.Command{
265+
Use: "list",
266+
RunE: func(cmd *cobra.Command, _ []string) error {
267+
users := fetchUsers()
268+
269+
if exporter != nil {
270+
return exporter.Write(cli.IO(cmd), users)
271+
}
272+
273+
cli.Output(cmd).Table(rows)
274+
return nil
275+
},
276+
}
277+
278+
cli.AddJSONFlags(listCmd, &exporter, []string{"id", "name", "email", "status"})
279+
```
280+
281+
Usage: `myapp list --json id,name` outputs only the requested fields. Fields are extracted via `json` struct tags. For custom field handling, implement the `Exportable` interface:
282+
283+
```go
284+
func (u *User) ExportData(fields []string) map[string]any {
285+
return cli.StructExportData(u, fields)
286+
}
287+
```
288+
257289
## Printer
258290

259291
Package-level functions replaced by `Output` type:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Requires Go 1.24+.
7878
| Package | Description |
7979
|---------|-------------|
8080
| [`app`](app/) | Service lifecycle — config, logger, telemetry, server, graceful shutdown |
81-
| [`cli`](cli/) | CLI lifecycle — init, execute, error handling, help, completion, version check |
81+
| [`cli`](cli/) | CLI lifecycle — init, execute, IOStreams, `--json` export, error handling, help, completion |
8282

8383
### Server & Middleware
8484

cli/example_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,48 @@ func ExampleTest() {
9292
// Output: hello from test
9393
}
9494

95+
func ExampleAddJSONFlags() {
96+
type User struct {
97+
ID int `json:"id"`
98+
Name string `json:"name"`
99+
Email string `json:"email"`
100+
Status string `json:"status"`
101+
}
102+
103+
var exporter cli.Exporter
104+
105+
listCmd := &cobra.Command{
106+
Use: "list",
107+
RunE: func(cmd *cobra.Command, _ []string) error {
108+
users := []User{
109+
{ID: 1, Name: "Alice", Email: "alice@example.com", Status: "active"},
110+
{ID: 2, Name: "Bob", Email: "bob@example.com", Status: "inactive"},
111+
}
112+
113+
// If --json was used, write structured output and return.
114+
if exporter != nil {
115+
return exporter.Write(cli.IO(cmd), users)
116+
}
117+
118+
// Otherwise, render a human-readable table.
119+
out := cli.Output(cmd)
120+
out.Table([][]string{
121+
{"ID", "NAME", "STATUS"},
122+
{"1", "Alice", "active"},
123+
{"2", "Bob", "inactive"},
124+
})
125+
return nil
126+
},
127+
}
128+
129+
cli.AddJSONFlags(listCmd, &exporter, []string{"id", "name", "email", "status"})
130+
131+
rootCmd := &cobra.Command{Use: "myapp"}
132+
rootCmd.AddCommand(listCmd)
133+
cli.Init(rootCmd)
134+
cli.Execute(rootCmd)
135+
}
136+
95137
func ExampleInit_withTopics() {
96138
rootCmd := &cobra.Command{
97139
Use: "myapp",

cli/export.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"reflect"
9+
"sort"
10+
"strings"
11+
12+
"github.com/spf13/cobra"
13+
)
14+
15+
// Exporter controls structured JSON output for a command.
16+
// When non-nil, the command should call Write instead of rendering
17+
// human-readable output.
18+
//
19+
// A nil Exporter means the user did not request --json.
20+
type Exporter interface {
21+
// Fields returns the field names requested via --json.
22+
Fields() []string
23+
// Write encodes data as JSON and writes it to the IOStreams.
24+
// On a TTY it writes indented, colorized JSON; when piped it
25+
// writes compact JSON.
26+
Write(ios *IOStreams, data any) error
27+
}
28+
29+
// Exportable may be implemented by structs to control which fields
30+
// are exported and how. When a struct implements this interface,
31+
// the JSON exporter calls ExportData instead of using reflection.
32+
//
33+
// func (r *Resource) ExportData(fields []string) map[string]any {
34+
// return cli.StructExportData(r, fields)
35+
// }
36+
type Exportable interface {
37+
ExportData(fields []string) map[string]any
38+
}
39+
40+
// AddJSONFlags adds a --json flag to cmd that accepts a comma-separated
41+
// list of field names. When --json is used, exportTarget is set to a
42+
// non-nil Exporter in a PreRunE hook. The command should check for a
43+
// non-nil Exporter and call Write instead of rendering a table.
44+
//
45+
// var exporter cli.Exporter
46+
// cli.AddJSONFlags(cmd, &exporter, []string{"id", "name", "status"})
47+
//
48+
// // In RunE:
49+
// if exporter != nil {
50+
// return exporter.Write(cli.IO(cmd), results)
51+
// }
52+
// cli.Output(cmd).Table(rows)
53+
func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) {
54+
cmd.Flags().StringSlice("json", nil, "Output JSON with the specified `fields`")
55+
56+
// Shell completion for field names.
57+
_ = cmd.RegisterFlagCompletionFunc("json", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
58+
var results []string
59+
var prefix string
60+
if idx := strings.LastIndexByte(toComplete, ','); idx >= 0 {
61+
prefix = toComplete[:idx+1]
62+
toComplete = toComplete[idx+1:]
63+
}
64+
toComplete = strings.ToLower(toComplete)
65+
for _, f := range fields {
66+
if strings.HasPrefix(strings.ToLower(f), toComplete) {
67+
results = append(results, prefix+f)
68+
}
69+
}
70+
sort.Strings(results)
71+
return results, cobra.ShellCompDirectiveNoSpace
72+
})
73+
74+
// Validate field names and set the exporter.
75+
oldPreRun := cmd.PreRunE
76+
cmd.PreRunE = func(c *cobra.Command, args []string) error {
77+
if oldPreRun != nil {
78+
if err := oldPreRun(c, args); err != nil {
79+
return err
80+
}
81+
}
82+
83+
jsonFlag := c.Flags().Lookup("json")
84+
if jsonFlag == nil || !jsonFlag.Changed {
85+
*exportTarget = nil
86+
return nil
87+
}
88+
89+
requested, _ := c.Flags().GetStringSlice("json")
90+
allowed := make(map[string]bool, len(fields))
91+
for _, f := range fields {
92+
allowed[f] = true
93+
}
94+
for _, f := range requested {
95+
if !allowed[f] {
96+
sorted := make([]string, len(fields))
97+
copy(sorted, fields)
98+
sort.Strings(sorted)
99+
return fmt.Errorf("unknown JSON field: %q\nAvailable fields:\n %s", f, strings.Join(sorted, "\n "))
100+
}
101+
}
102+
103+
*exportTarget = &jsonExporter{fields: requested}
104+
return nil
105+
}
106+
107+
// When --json is passed without arguments, list available fields.
108+
parentFlagErr := cmd.FlagErrorFunc()
109+
cmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
110+
if c == cmd && err.Error() == "flag needs an argument: --json" {
111+
sorted := make([]string, len(fields))
112+
copy(sorted, fields)
113+
sort.Strings(sorted)
114+
return fmt.Errorf("specify one or more comma-separated fields for --json:\n %s", strings.Join(sorted, "\n "))
115+
}
116+
if parentFlagErr != nil {
117+
return parentFlagErr(c, err)
118+
}
119+
return err
120+
})
121+
122+
// Annotate for help display.
123+
if len(fields) > 0 {
124+
if cmd.Annotations == nil {
125+
cmd.Annotations = map[string]string{}
126+
}
127+
cmd.Annotations["help:json-fields"] = strings.Join(fields, ",")
128+
}
129+
}
130+
131+
// StructExportData extracts the requested fields from a struct using
132+
// case-insensitive field name matching. Use this as a default
133+
// implementation for [Exportable.ExportData]:
134+
//
135+
// func (r *Resource) ExportData(fields []string) map[string]any {
136+
// return cli.StructExportData(r, fields)
137+
// }
138+
func StructExportData(s any, fields []string) map[string]any {
139+
v := reflect.ValueOf(s)
140+
if v.Kind() == reflect.Ptr {
141+
v = v.Elem()
142+
}
143+
if v.Kind() != reflect.Struct {
144+
return nil
145+
}
146+
data := make(map[string]any, len(fields))
147+
for _, f := range fields {
148+
sf := fieldByTag(v, f)
149+
if !sf.IsValid() {
150+
sf = fieldByName(v, f)
151+
}
152+
if sf.IsValid() && sf.CanInterface() {
153+
data[f] = sf.Interface()
154+
}
155+
}
156+
return data
157+
}
158+
159+
// jsonExporter is the default Exporter implementation.
160+
type jsonExporter struct {
161+
fields []string
162+
}
163+
164+
func (e *jsonExporter) Fields() []string {
165+
return e.fields
166+
}
167+
168+
func (e *jsonExporter) Write(ios *IOStreams, data any) error {
169+
extracted := e.extractData(reflect.ValueOf(data))
170+
171+
buf := &bytes.Buffer{}
172+
enc := json.NewEncoder(buf)
173+
enc.SetEscapeHTML(false)
174+
if err := enc.Encode(extracted); err != nil {
175+
return err
176+
}
177+
178+
w := ios.Out
179+
if ios.IsStdoutTTY() {
180+
// Re-encode with indentation for readability.
181+
var pretty bytes.Buffer
182+
if err := json.Indent(&pretty, buf.Bytes(), "", " "); err != nil {
183+
// Fallback to compact.
184+
_, err = io.Copy(w, buf)
185+
return err
186+
}
187+
pretty.WriteByte('\n')
188+
_, err := io.Copy(w, &pretty)
189+
return err
190+
}
191+
192+
_, err := io.Copy(w, buf)
193+
return err
194+
}
195+
196+
func (e *jsonExporter) extractData(v reflect.Value) any {
197+
switch v.Kind() {
198+
case reflect.Ptr, reflect.Interface:
199+
if !v.IsNil() {
200+
return e.extractData(v.Elem())
201+
}
202+
return nil
203+
case reflect.Slice:
204+
a := make([]any, v.Len())
205+
for i := 0; i < v.Len(); i++ {
206+
a[i] = e.extractData(v.Index(i))
207+
}
208+
return a
209+
case reflect.Struct:
210+
if v.CanAddr() {
211+
if ex, ok := v.Addr().Interface().(Exportable); ok {
212+
return ex.ExportData(e.fields)
213+
}
214+
}
215+
if ex, ok := v.Interface().(Exportable); ok {
216+
return ex.ExportData(e.fields)
217+
}
218+
return StructExportData(v.Interface(), e.fields)
219+
}
220+
return v.Interface()
221+
}
222+
223+
// fieldByTag finds a struct field whose `json` tag matches the given name.
224+
func fieldByTag(v reflect.Value, name string) reflect.Value {
225+
t := v.Type()
226+
for i := 0; i < t.NumField(); i++ {
227+
tag := t.Field(i).Tag.Get("json")
228+
if idx := strings.IndexByte(tag, ','); idx >= 0 {
229+
tag = tag[:idx]
230+
}
231+
if strings.EqualFold(tag, name) {
232+
return v.Field(i)
233+
}
234+
}
235+
return reflect.Value{}
236+
}
237+
238+
// fieldByName finds a struct field by case-insensitive name match.
239+
func fieldByName(v reflect.Value, name string) reflect.Value {
240+
return v.FieldByNameFunc(func(s string) bool {
241+
return strings.EqualFold(name, s)
242+
})
243+
}

0 commit comments

Comments
 (0)