Skip to content

Commit 0aebfac

Browse files
authored
Merge pull request cli#3011 from cli/api-template
Add a template `--format` flag to api command
2 parents aa5cf6c + eb08774 commit 0aebfac

File tree

4 files changed

+408
-1
lines changed

4 files changed

+408
-1
lines changed

pkg/cmd/api/api.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type ApiOptions struct {
4141
ShowResponseHeaders bool
4242
Paginate bool
4343
Silent bool
44+
Template string
4445
CacheTTL time.Duration
4546

4647
HttpClient func() (*http.Client, error)
@@ -95,6 +96,17 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
9596
there are no more pages of results. For GraphQL requests, this requires that the
9697
original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the
9798
%[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection.
99+
100+
With %[1]s--template%[1]s, the provided Go template is rendered using the JSON data as input.
101+
For the syntax of Go templates, see: https://golang.org/pkg/text/template/
102+
103+
The following functions are available in templates:
104+
- %[1]scolor <style>, <input>%[1]s: colorize input using https://github.com/mgutz/ansi
105+
- %[1]sautocolor%[1]s: like %[1]scolor%[1]s, but only emits color to terminals
106+
- %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function
107+
- %[1]stimeago <time>%[1]s: renders a timestamp as relative to now
108+
- %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input
109+
- %[1]sjoin <sep> <list>%[1]s: joins values in the list using a separator
98110
`, "`"),
99111
Example: heredoc.Doc(`
100112
# list releases in the current repository
@@ -112,6 +124,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
112124
# opt into GitHub API previews
113125
$ gh api --preview baptiste,nebula ...
114126
127+
# use a template for the output
128+
$ gh api repos/:owner/:repo/issues --template \
129+
'{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}'
130+
115131
# list releases with GraphQL
116132
$ gh api graphql -F owner=':owner' -F name=':repo' -f query='
117133
query($name: String!, $owner: String!) {
@@ -184,6 +200,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
184200
cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
185201
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request")
186202
cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body")
203+
cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format the response using a Go template")
187204
cmd.Flags().DurationVar(&opts.CacheTTL, "cache", 0, "Cache the response, e.g. \"3600s\", \"60m\", \"1h\"")
188205
return cmd
189206
}
@@ -315,7 +332,13 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
315332
responseBody = io.TeeReader(responseBody, bodyCopy)
316333
}
317334

318-
if isJSON && opts.IO.ColorEnabled() {
335+
if opts.Template != "" {
336+
// TODO: reuse parsed template across pagination invocations
337+
err = executeTemplate(opts.IO.Out, responseBody, opts.Template, opts.IO.ColorEnabled())
338+
if err != nil {
339+
return
340+
}
341+
} else if isJSON && opts.IO.ColorEnabled() {
319342
err = jsoncolor.Write(opts.IO.Out, responseBody, " ")
320343
} else {
321344
_, err = io.Copy(opts.IO.Out, responseBody)

pkg/cmd/api/api_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88
"net/http"
99
"os"
1010
"path/filepath"
11+
"strings"
1112
"testing"
1213
"time"
1314

15+
"github.com/MakeNowJust/heredoc"
1416
"github.com/cli/cli/git"
1517
"github.com/cli/cli/internal/ghrepo"
1618
"github.com/cli/cli/pkg/cmdutil"
@@ -45,6 +47,7 @@ func Test_NewCmdApi(t *testing.T) {
4547
Paginate: false,
4648
Silent: false,
4749
CacheTTL: 0,
50+
Template: "",
4851
},
4952
wantsErr: false,
5053
},
@@ -64,6 +67,7 @@ func Test_NewCmdApi(t *testing.T) {
6467
Paginate: false,
6568
Silent: false,
6669
CacheTTL: 0,
70+
Template: "",
6771
},
6872
wantsErr: false,
6973
},
@@ -83,6 +87,7 @@ func Test_NewCmdApi(t *testing.T) {
8387
Paginate: false,
8488
Silent: false,
8589
CacheTTL: 0,
90+
Template: "",
8691
},
8792
wantsErr: false,
8893
},
@@ -102,6 +107,7 @@ func Test_NewCmdApi(t *testing.T) {
102107
Paginate: false,
103108
Silent: false,
104109
CacheTTL: 0,
110+
Template: "",
105111
},
106112
wantsErr: false,
107113
},
@@ -121,6 +127,7 @@ func Test_NewCmdApi(t *testing.T) {
121127
Paginate: true,
122128
Silent: false,
123129
CacheTTL: 0,
130+
Template: "",
124131
},
125132
wantsErr: false,
126133
},
@@ -140,6 +147,7 @@ func Test_NewCmdApi(t *testing.T) {
140147
Paginate: false,
141148
Silent: true,
142149
CacheTTL: 0,
150+
Template: "",
143151
},
144152
wantsErr: false,
145153
},
@@ -164,6 +172,7 @@ func Test_NewCmdApi(t *testing.T) {
164172
Paginate: true,
165173
Silent: false,
166174
CacheTTL: 0,
175+
Template: "",
167176
},
168177
wantsErr: false,
169178
},
@@ -188,6 +197,7 @@ func Test_NewCmdApi(t *testing.T) {
188197
Paginate: false,
189198
Silent: false,
190199
CacheTTL: 0,
200+
Template: "",
191201
},
192202
wantsErr: false,
193203
},
@@ -212,6 +222,7 @@ func Test_NewCmdApi(t *testing.T) {
212222
Paginate: false,
213223
Silent: false,
214224
CacheTTL: 0,
225+
Template: "",
215226
},
216227
wantsErr: false,
217228
},
@@ -231,6 +242,27 @@ func Test_NewCmdApi(t *testing.T) {
231242
Paginate: false,
232243
Silent: false,
233244
CacheTTL: time.Minute * 5,
245+
Template: "",
246+
},
247+
wantsErr: false,
248+
},
249+
{
250+
name: "with template",
251+
cli: "user -t 'hello {{.name}}'",
252+
wants: ApiOptions{
253+
Hostname: "",
254+
RequestMethod: "GET",
255+
RequestMethodPassed: false,
256+
RequestPath: "user",
257+
RequestInputFile: "",
258+
RawFields: []string(nil),
259+
MagicFields: []string(nil),
260+
RequestHeaders: []string(nil),
261+
ShowResponseHeaders: false,
262+
Paginate: false,
263+
Silent: false,
264+
CacheTTL: 0,
265+
Template: "hello {{.name}}",
234266
},
235267
wantsErr: false,
236268
},
@@ -268,6 +300,7 @@ func Test_NewCmdApi(t *testing.T) {
268300
assert.Equal(t, tt.wants.Paginate, opts.Paginate)
269301
assert.Equal(t, tt.wants.Silent, opts.Silent)
270302
assert.Equal(t, tt.wants.CacheTTL, opts.CacheTTL)
303+
assert.Equal(t, tt.wants.Template, opts.Template)
271304
})
272305
}
273306
}
@@ -393,6 +426,20 @@ func Test_apiRun(t *testing.T) {
393426
stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\n",
394427
stderr: ``,
395428
},
429+
{
430+
name: "output template",
431+
options: ApiOptions{
432+
Template: `{{.status}}`,
433+
},
434+
httpResponse: &http.Response{
435+
StatusCode: 200,
436+
Body: ioutil.NopCloser(bytes.NewBufferString(`{"status":"not a cat"}`)),
437+
Header: http.Header{"Content-Type": []string{"application/json"}},
438+
},
439+
err: nil,
440+
stdout: "not a cat",
441+
stderr: ``,
442+
},
396443
}
397444

398445
for _, tt := range tests {
@@ -936,3 +983,40 @@ func Test_previewNamesToMIMETypes(t *testing.T) {
936983
})
937984
}
938985
}
986+
987+
func Test_processResponse_template(t *testing.T) {
988+
io, _, stdout, stderr := iostreams.Test()
989+
990+
resp := http.Response{
991+
StatusCode: 200,
992+
Header: map[string][]string{
993+
"Content-Type": {"application/json"},
994+
},
995+
Body: ioutil.NopCloser(strings.NewReader(`[
996+
{
997+
"title": "First title",
998+
"labels": [{"name":"bug"}, {"name":"help wanted"}]
999+
},
1000+
{
1001+
"title": "Second but not last"
1002+
},
1003+
{
1004+
"title": "Alas, tis' the end",
1005+
"labels": [{}, {"name":"feature"}]
1006+
}
1007+
]`)),
1008+
}
1009+
1010+
_, err := processResponse(&resp, &ApiOptions{
1011+
IO: io,
1012+
Template: `{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " }}){{"\n"}}{{end}}`,
1013+
}, ioutil.Discard)
1014+
require.NoError(t, err)
1015+
1016+
assert.Equal(t, heredoc.Doc(`
1017+
First title (bug, help wanted)
1018+
Second but not last ()
1019+
Alas, tis' the end (, feature)
1020+
`), stdout.String())
1021+
assert.Equal(t, "", stderr.String())
1022+
}

pkg/cmd/api/template.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"io/ioutil"
8+
"math"
9+
"strconv"
10+
"strings"
11+
"text/template"
12+
"time"
13+
14+
"github.com/cli/cli/utils"
15+
"github.com/mgutz/ansi"
16+
)
17+
18+
func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
19+
now := time.Now()
20+
21+
templateFuncs := map[string]interface{}{
22+
"color": templateColor,
23+
"autocolor": templateColor,
24+
25+
"timefmt": func(format, input string) (string, error) {
26+
t, err := time.Parse(time.RFC3339, input)
27+
if err != nil {
28+
return "", err
29+
}
30+
return t.Format(format), nil
31+
},
32+
"timeago": func(input string) (string, error) {
33+
t, err := time.Parse(time.RFC3339, input)
34+
if err != nil {
35+
return "", err
36+
}
37+
return timeAgo(now.Sub(t)), nil
38+
},
39+
40+
"pluck": templatePluck,
41+
"join": templateJoin,
42+
}
43+
44+
if !colorEnabled {
45+
templateFuncs["autocolor"] = func(colorName string, input interface{}) (string, error) {
46+
return jsonScalarToString(input)
47+
}
48+
}
49+
50+
return template.New("").Funcs(templateFuncs).Parse(tpl)
51+
}
52+
53+
func executeTemplate(w io.Writer, input io.Reader, templateStr string, colorEnabled bool) error {
54+
t, err := parseTemplate(templateStr, colorEnabled)
55+
if err != nil {
56+
return err
57+
}
58+
59+
jsonData, err := ioutil.ReadAll(input)
60+
if err != nil {
61+
return err
62+
}
63+
64+
var data interface{}
65+
if err := json.Unmarshal(jsonData, &data); err != nil {
66+
return err
67+
}
68+
69+
return t.Execute(w, data)
70+
}
71+
72+
func jsonScalarToString(input interface{}) (string, error) {
73+
switch tt := input.(type) {
74+
case string:
75+
return tt, nil
76+
case float64:
77+
if math.Trunc(tt) == tt {
78+
return strconv.FormatFloat(tt, 'f', 0, 64), nil
79+
} else {
80+
return strconv.FormatFloat(tt, 'f', 2, 64), nil
81+
}
82+
case nil:
83+
return "", nil
84+
case bool:
85+
return fmt.Sprintf("%v", tt), nil
86+
default:
87+
return "", fmt.Errorf("cannot convert type to string: %v", tt)
88+
}
89+
}
90+
91+
func templateColor(colorName string, input interface{}) (string, error) {
92+
text, err := jsonScalarToString(input)
93+
if err != nil {
94+
return "", err
95+
}
96+
return ansi.Color(text, colorName), nil
97+
}
98+
99+
func templatePluck(field string, input []interface{}) []interface{} {
100+
var results []interface{}
101+
for _, item := range input {
102+
obj := item.(map[string]interface{})
103+
results = append(results, obj[field])
104+
}
105+
return results
106+
}
107+
108+
func templateJoin(sep string, input []interface{}) (string, error) {
109+
var results []string
110+
for _, item := range input {
111+
text, err := jsonScalarToString(item)
112+
if err != nil {
113+
return "", err
114+
}
115+
results = append(results, text)
116+
}
117+
return strings.Join(results, sep), nil
118+
}
119+
120+
func timeAgo(ago time.Duration) string {
121+
if ago < time.Minute {
122+
return "just now"
123+
}
124+
if ago < time.Hour {
125+
return utils.Pluralize(int(ago.Minutes()), "minute") + " ago"
126+
}
127+
if ago < 24*time.Hour {
128+
return utils.Pluralize(int(ago.Hours()), "hour") + " ago"
129+
}
130+
if ago < 30*24*time.Hour {
131+
return utils.Pluralize(int(ago.Hours())/24, "day") + " ago"
132+
}
133+
if ago < 365*24*time.Hour {
134+
return utils.Pluralize(int(ago.Hours())/24/30, "month") + " ago"
135+
}
136+
return utils.Pluralize(int(ago.Hours()/24/365), "year") + " ago"
137+
}

0 commit comments

Comments
 (0)