Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d60ec3e

Browse files
authored
feat: add JSON output format to many CLI commands (#6082)
1 parent 5655ec6 commit d60ec3e

36 files changed

+851
-285
lines changed

cli/cliui/output.go

+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package cliui
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"reflect"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
"golang.org/x/xerrors"
11+
)
12+
13+
type OutputFormat interface {
14+
ID() string
15+
AttachFlags(cmd *cobra.Command)
16+
Format(ctx context.Context, data any) (string, error)
17+
}
18+
19+
type OutputFormatter struct {
20+
formats []OutputFormat
21+
formatID string
22+
}
23+
24+
// NewOutputFormatter creates a new OutputFormatter with the given formats. The
25+
// first format is the default format. At least two formats must be provided.
26+
func NewOutputFormatter(formats ...OutputFormat) *OutputFormatter {
27+
if len(formats) < 2 {
28+
panic("at least two output formats must be provided")
29+
}
30+
31+
formatIDs := make(map[string]struct{}, len(formats))
32+
for _, format := range formats {
33+
if format.ID() == "" {
34+
panic("output format ID must not be empty")
35+
}
36+
if _, ok := formatIDs[format.ID()]; ok {
37+
panic("duplicate format ID: " + format.ID())
38+
}
39+
formatIDs[format.ID()] = struct{}{}
40+
}
41+
42+
return &OutputFormatter{
43+
formats: formats,
44+
formatID: formats[0].ID(),
45+
}
46+
}
47+
48+
// AttachFlags attaches the --output flag to the given command, and any
49+
// additional flags required by the output formatters.
50+
func (f *OutputFormatter) AttachFlags(cmd *cobra.Command) {
51+
for _, format := range f.formats {
52+
format.AttachFlags(cmd)
53+
}
54+
55+
formatNames := make([]string, 0, len(f.formats))
56+
for _, format := range f.formats {
57+
formatNames = append(formatNames, format.ID())
58+
}
59+
60+
cmd.Flags().StringVarP(&f.formatID, "output", "o", f.formats[0].ID(), "Output format. Available formats: "+strings.Join(formatNames, ", "))
61+
}
62+
63+
// Format formats the given data using the format specified by the --output
64+
// flag. If the flag is not set, the default format is used.
65+
func (f *OutputFormatter) Format(ctx context.Context, data any) (string, error) {
66+
for _, format := range f.formats {
67+
if format.ID() == f.formatID {
68+
return format.Format(ctx, data)
69+
}
70+
}
71+
72+
return "", xerrors.Errorf("unknown output format %q", f.formatID)
73+
}
74+
75+
type tableFormat struct {
76+
defaultColumns []string
77+
allColumns []string
78+
sort string
79+
80+
columns []string
81+
}
82+
83+
var _ OutputFormat = &tableFormat{}
84+
85+
// TableFormat creates a table formatter for the given output type. The output
86+
// type should be specified as an empty slice of the desired type.
87+
//
88+
// E.g.: TableFormat([]MyType{}, []string{"foo", "bar"})
89+
//
90+
// defaultColumns is optional and specifies the default columns to display. If
91+
// not specified, all columns are displayed by default.
92+
func TableFormat(out any, defaultColumns []string) OutputFormat {
93+
v := reflect.Indirect(reflect.ValueOf(out))
94+
if v.Kind() != reflect.Slice {
95+
panic("DisplayTable called with a non-slice type")
96+
}
97+
98+
// Get the list of table column headers.
99+
headers, defaultSort, err := typeToTableHeaders(v.Type().Elem())
100+
if err != nil {
101+
panic("parse table headers: " + err.Error())
102+
}
103+
104+
tf := &tableFormat{
105+
defaultColumns: headers,
106+
allColumns: headers,
107+
sort: defaultSort,
108+
}
109+
if len(defaultColumns) > 0 {
110+
tf.defaultColumns = defaultColumns
111+
}
112+
113+
return tf
114+
}
115+
116+
// ID implements OutputFormat.
117+
func (*tableFormat) ID() string {
118+
return "table"
119+
}
120+
121+
// AttachFlags implements OutputFormat.
122+
func (f *tableFormat) AttachFlags(cmd *cobra.Command) {
123+
cmd.Flags().StringSliceVarP(&f.columns, "column", "c", f.defaultColumns, "Columns to display in table output. Available columns: "+strings.Join(f.allColumns, ", "))
124+
}
125+
126+
// Format implements OutputFormat.
127+
func (f *tableFormat) Format(_ context.Context, data any) (string, error) {
128+
return DisplayTable(data, f.sort, f.columns)
129+
}
130+
131+
type jsonFormat struct{}
132+
133+
var _ OutputFormat = jsonFormat{}
134+
135+
// JSONFormat creates a JSON formatter.
136+
func JSONFormat() OutputFormat {
137+
return jsonFormat{}
138+
}
139+
140+
// ID implements OutputFormat.
141+
func (jsonFormat) ID() string {
142+
return "json"
143+
}
144+
145+
// AttachFlags implements OutputFormat.
146+
func (jsonFormat) AttachFlags(_ *cobra.Command) {}
147+
148+
// Format implements OutputFormat.
149+
func (jsonFormat) Format(_ context.Context, data any) (string, error) {
150+
outBytes, err := json.MarshalIndent(data, "", " ")
151+
if err != nil {
152+
return "", xerrors.Errorf("marshal output to JSON: %w", err)
153+
}
154+
155+
return string(outBytes), nil
156+
}

cli/cliui/output_test.go

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package cliui_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"sync/atomic"
7+
"testing"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/coder/coder/cli/cliui"
13+
)
14+
15+
type format struct {
16+
id string
17+
attachFlagsFn func(cmd *cobra.Command)
18+
formatFn func(ctx context.Context, data any) (string, error)
19+
}
20+
21+
var _ cliui.OutputFormat = &format{}
22+
23+
func (f *format) ID() string {
24+
return f.id
25+
}
26+
27+
func (f *format) AttachFlags(cmd *cobra.Command) {
28+
if f.attachFlagsFn != nil {
29+
f.attachFlagsFn(cmd)
30+
}
31+
}
32+
33+
func (f *format) Format(ctx context.Context, data any) (string, error) {
34+
if f.formatFn != nil {
35+
return f.formatFn(ctx, data)
36+
}
37+
38+
return "", nil
39+
}
40+
41+
func Test_OutputFormatter(t *testing.T) {
42+
t.Parallel()
43+
44+
t.Run("RequiresTwoFormatters", func(t *testing.T) {
45+
t.Parallel()
46+
47+
require.Panics(t, func() {
48+
cliui.NewOutputFormatter()
49+
})
50+
require.Panics(t, func() {
51+
cliui.NewOutputFormatter(cliui.JSONFormat())
52+
})
53+
})
54+
55+
t.Run("NoMissingFormatID", func(t *testing.T) {
56+
t.Parallel()
57+
58+
require.Panics(t, func() {
59+
cliui.NewOutputFormatter(
60+
cliui.JSONFormat(),
61+
&format{id: ""},
62+
)
63+
})
64+
})
65+
66+
t.Run("NoDuplicateFormats", func(t *testing.T) {
67+
t.Parallel()
68+
69+
require.Panics(t, func() {
70+
cliui.NewOutputFormatter(
71+
cliui.JSONFormat(),
72+
cliui.JSONFormat(),
73+
)
74+
})
75+
})
76+
77+
t.Run("OK", func(t *testing.T) {
78+
t.Parallel()
79+
80+
var called int64
81+
f := cliui.NewOutputFormatter(
82+
cliui.JSONFormat(),
83+
&format{
84+
id: "foo",
85+
attachFlagsFn: func(cmd *cobra.Command) {
86+
cmd.Flags().StringP("foo", "f", "", "foo flag 1234")
87+
},
88+
formatFn: func(_ context.Context, _ any) (string, error) {
89+
atomic.AddInt64(&called, 1)
90+
return "foo", nil
91+
},
92+
},
93+
)
94+
95+
cmd := &cobra.Command{}
96+
f.AttachFlags(cmd)
97+
98+
selected, err := cmd.Flags().GetString("output")
99+
require.NoError(t, err)
100+
require.Equal(t, "json", selected)
101+
usage := cmd.Flags().FlagUsages()
102+
require.Contains(t, usage, "Available formats: json, foo")
103+
require.Contains(t, usage, "foo flag 1234")
104+
105+
ctx := context.Background()
106+
data := []string{"hi", "dean", "was", "here"}
107+
out, err := f.Format(ctx, data)
108+
require.NoError(t, err)
109+
110+
var got []string
111+
require.NoError(t, json.Unmarshal([]byte(out), &got))
112+
require.Equal(t, data, got)
113+
require.EqualValues(t, 0, atomic.LoadInt64(&called))
114+
115+
require.NoError(t, cmd.Flags().Set("output", "foo"))
116+
out, err = f.Format(ctx, data)
117+
require.NoError(t, err)
118+
require.Equal(t, "foo", out)
119+
require.EqualValues(t, 1, atomic.LoadInt64(&called))
120+
121+
require.NoError(t, cmd.Flags().Set("output", "bar"))
122+
out, err = f.Format(ctx, data)
123+
require.Error(t, err)
124+
require.ErrorContains(t, err, "bar")
125+
require.Equal(t, "", out)
126+
require.EqualValues(t, 1, atomic.LoadInt64(&called))
127+
})
128+
}

0 commit comments

Comments
 (0)