Merge pull request #3011 from cli/api-template

Add a template `--format` flag to api command
This commit is contained in:
Mislav Marohnić 2021-03-04 17:46:49 +01:00 committed by GitHub
commit 0aebfacd95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 408 additions and 1 deletions

View file

@ -41,6 +41,7 @@ type ApiOptions struct {
ShowResponseHeaders bool
Paginate bool
Silent bool
Template string
CacheTTL time.Duration
HttpClient func() (*http.Client, error)
@ -95,6 +96,17 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
there are no more pages of results. For GraphQL requests, this requires that the
original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the
%[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection.
With %[1]s--template%[1]s, the provided Go template is rendered using the JSON data as input.
For the syntax of Go templates, see: https://golang.org/pkg/text/template/
The following functions are available in templates:
- %[1]scolor <style>, <input>%[1]s: colorize input using https://github.com/mgutz/ansi
- %[1]sautocolor%[1]s: like %[1]scolor%[1]s, but only emits color to terminals
- %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function
- %[1]stimeago <time>%[1]s: renders a timestamp as relative to now
- %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input
- %[1]sjoin <sep> <list>%[1]s: joins values in the list using a separator
`, "`"),
Example: heredoc.Doc(`
# list releases in the current repository
@ -112,6 +124,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
# opt into GitHub API previews
$ gh api --preview baptiste,nebula ...
# use a template for the output
$ gh api repos/:owner/:repo/issues --template \
'{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}'
# list releases with GraphQL
$ gh api graphql -F owner=':owner' -F name=':repo' -f query='
query($name: String!, $owner: String!) {
@ -184,6 +200,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request")
cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body")
cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format the response using a Go template")
cmd.Flags().DurationVar(&opts.CacheTTL, "cache", 0, "Cache the response, e.g. \"3600s\", \"60m\", \"1h\"")
return cmd
}
@ -315,7 +332,13 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
responseBody = io.TeeReader(responseBody, bodyCopy)
}
if isJSON && opts.IO.ColorEnabled() {
if opts.Template != "" {
// TODO: reuse parsed template across pagination invocations
err = executeTemplate(opts.IO.Out, responseBody, opts.Template, opts.IO.ColorEnabled())
if err != nil {
return
}
} else if isJSON && opts.IO.ColorEnabled() {
err = jsoncolor.Write(opts.IO.Out, responseBody, " ")
} else {
_, err = io.Copy(opts.IO.Out, responseBody)

View file

@ -8,9 +8,11 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
@ -45,6 +47,7 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
},
wantsErr: false,
},
@ -64,6 +67,7 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
},
wantsErr: false,
},
@ -83,6 +87,7 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
},
wantsErr: false,
},
@ -102,6 +107,7 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
},
wantsErr: false,
},
@ -121,6 +127,7 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: true,
Silent: false,
CacheTTL: 0,
Template: "",
},
wantsErr: false,
},
@ -140,6 +147,7 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: false,
Silent: true,
CacheTTL: 0,
Template: "",
},
wantsErr: false,
},
@ -164,6 +172,7 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: true,
Silent: false,
CacheTTL: 0,
Template: "",
},
wantsErr: false,
},
@ -188,6 +197,7 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
},
wantsErr: false,
},
@ -212,6 +222,7 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
},
wantsErr: false,
},
@ -231,6 +242,27 @@ func Test_NewCmdApi(t *testing.T) {
Paginate: false,
Silent: false,
CacheTTL: time.Minute * 5,
Template: "",
},
wantsErr: false,
},
{
name: "with template",
cli: "user -t 'hello {{.name}}'",
wants: ApiOptions{
Hostname: "",
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "user",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
ShowResponseHeaders: false,
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "hello {{.name}}",
},
wantsErr: false,
},
@ -268,6 +300,7 @@ func Test_NewCmdApi(t *testing.T) {
assert.Equal(t, tt.wants.Paginate, opts.Paginate)
assert.Equal(t, tt.wants.Silent, opts.Silent)
assert.Equal(t, tt.wants.CacheTTL, opts.CacheTTL)
assert.Equal(t, tt.wants.Template, opts.Template)
})
}
}
@ -393,6 +426,20 @@ func Test_apiRun(t *testing.T) {
stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\n",
stderr: ``,
},
{
name: "output template",
options: ApiOptions{
Template: `{{.status}}`,
},
httpResponse: &http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(`{"status":"not a cat"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
},
err: nil,
stdout: "not a cat",
stderr: ``,
},
}
for _, tt := range tests {
@ -936,3 +983,40 @@ func Test_previewNamesToMIMETypes(t *testing.T) {
})
}
}
func Test_processResponse_template(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
resp := http.Response{
StatusCode: 200,
Header: map[string][]string{
"Content-Type": {"application/json"},
},
Body: ioutil.NopCloser(strings.NewReader(`[
{
"title": "First title",
"labels": [{"name":"bug"}, {"name":"help wanted"}]
},
{
"title": "Second but not last"
},
{
"title": "Alas, tis' the end",
"labels": [{}, {"name":"feature"}]
}
]`)),
}
_, err := processResponse(&resp, &ApiOptions{
IO: io,
Template: `{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " }}){{"\n"}}{{end}}`,
}, ioutil.Discard)
require.NoError(t, err)
assert.Equal(t, heredoc.Doc(`
First title (bug, help wanted)
Second but not last ()
Alas, tis' the end (, feature)
`), stdout.String())
assert.Equal(t, "", stderr.String())
}

137
pkg/cmd/api/template.go Normal file
View file

@ -0,0 +1,137 @@
package api
import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"math"
"strconv"
"strings"
"text/template"
"time"
"github.com/cli/cli/utils"
"github.com/mgutz/ansi"
)
func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
now := time.Now()
templateFuncs := map[string]interface{}{
"color": templateColor,
"autocolor": templateColor,
"timefmt": func(format, input string) (string, error) {
t, err := time.Parse(time.RFC3339, input)
if err != nil {
return "", err
}
return t.Format(format), nil
},
"timeago": func(input string) (string, error) {
t, err := time.Parse(time.RFC3339, input)
if err != nil {
return "", err
}
return timeAgo(now.Sub(t)), nil
},
"pluck": templatePluck,
"join": templateJoin,
}
if !colorEnabled {
templateFuncs["autocolor"] = func(colorName string, input interface{}) (string, error) {
return jsonScalarToString(input)
}
}
return template.New("").Funcs(templateFuncs).Parse(tpl)
}
func executeTemplate(w io.Writer, input io.Reader, templateStr string, colorEnabled bool) error {
t, err := parseTemplate(templateStr, colorEnabled)
if err != nil {
return err
}
jsonData, err := ioutil.ReadAll(input)
if err != nil {
return err
}
var data interface{}
if err := json.Unmarshal(jsonData, &data); err != nil {
return err
}
return t.Execute(w, data)
}
func jsonScalarToString(input interface{}) (string, error) {
switch tt := input.(type) {
case string:
return tt, nil
case float64:
if math.Trunc(tt) == tt {
return strconv.FormatFloat(tt, 'f', 0, 64), nil
} else {
return strconv.FormatFloat(tt, 'f', 2, 64), nil
}
case nil:
return "", nil
case bool:
return fmt.Sprintf("%v", tt), nil
default:
return "", fmt.Errorf("cannot convert type to string: %v", tt)
}
}
func templateColor(colorName string, input interface{}) (string, error) {
text, err := jsonScalarToString(input)
if err != nil {
return "", err
}
return ansi.Color(text, colorName), nil
}
func templatePluck(field string, input []interface{}) []interface{} {
var results []interface{}
for _, item := range input {
obj := item.(map[string]interface{})
results = append(results, obj[field])
}
return results
}
func templateJoin(sep string, input []interface{}) (string, error) {
var results []string
for _, item := range input {
text, err := jsonScalarToString(item)
if err != nil {
return "", err
}
results = append(results, text)
}
return strings.Join(results, sep), nil
}
func timeAgo(ago time.Duration) string {
if ago < time.Minute {
return "just now"
}
if ago < time.Hour {
return utils.Pluralize(int(ago.Minutes()), "minute") + " ago"
}
if ago < 24*time.Hour {
return utils.Pluralize(int(ago.Hours()), "hour") + " ago"
}
if ago < 30*24*time.Hour {
return utils.Pluralize(int(ago.Hours())/24, "day") + " ago"
}
if ago < 365*24*time.Hour {
return utils.Pluralize(int(ago.Hours())/24/30, "month") + " ago"
}
return utils.Pluralize(int(ago.Hours()/24/365), "year") + " ago"
}

View file

@ -0,0 +1,163 @@
package api
import (
"bytes"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
)
func Test_jsonScalarToString(t *testing.T) {
tests := []struct {
name string
input interface{}
want string
wantErr bool
}{
{
name: "string",
input: "hello",
want: "hello",
},
{
name: "int",
input: float64(1234),
want: "1234",
},
{
name: "float",
input: float64(12.34),
want: "12.34",
},
{
name: "null",
input: nil,
want: "",
},
{
name: "true",
input: true,
want: "true",
},
{
name: "false",
input: false,
want: "false",
},
{
name: "object",
input: map[string]interface{}{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := jsonScalarToString(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("jsonScalarToString() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("jsonScalarToString() = %v, want %v", got, tt.want)
}
})
}
}
func Test_executeTemplate(t *testing.T) {
type args struct {
json io.Reader
template string
colorize bool
}
tests := []struct {
name string
args args
wantW string
wantErr bool
}{
{
name: "color",
args: args{
json: strings.NewReader(`{}`),
template: `{{color "blue+h" "songs are like tattoos"}}`,
colorize: false,
},
wantW: "\x1b[0;94msongs are like tattoos\x1b[0m",
},
{
name: "autocolor enabled",
args: args{
json: strings.NewReader(`{}`),
template: `{{autocolor "red" "stop"}}`,
colorize: true,
},
wantW: "\x1b[0;31mstop\x1b[0m",
},
{
name: "autocolor disabled",
args: args{
json: strings.NewReader(`{}`),
template: `{{autocolor "red" "go"}}`,
colorize: false,
},
wantW: "go",
},
{
name: "timefmt",
args: args{
json: strings.NewReader(`{"created_at":"2008-02-25T20:18:33Z"}`),
template: `{{.created_at | timefmt "Mon Jan 2, 2006"}}`,
colorize: false,
},
wantW: "Mon Feb 25, 2008",
},
{
name: "timeago",
args: args{
json: strings.NewReader(fmt.Sprintf(`{"created_at":"%s"}`, time.Now().Add(-5*time.Minute).Format(time.RFC3339))),
template: `{{.created_at | timeago}}`,
colorize: false,
},
wantW: "5 minutes ago",
},
{
name: "pluck",
args: args{
json: strings.NewReader(heredoc.Doc(`[
{"name": "bug"},
{"name": "feature request"},
{"name": "chore"}
]`)),
template: `{{range(pluck "name" .)}}{{. | printf "%s\n"}}{{end}}`,
colorize: false,
},
wantW: "bug\nfeature request\nchore\n",
},
{
name: "join",
args: args{
json: strings.NewReader(`[ "bug", "feature request", "chore" ]`),
template: `{{join "\t" .}}`,
colorize: false,
},
wantW: "bug\tfeature request\tchore",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &bytes.Buffer{}
if err := executeTemplate(w, tt.args.json, tt.args.template, tt.args.colorize); (err != nil) != tt.wantErr {
t.Errorf("executeTemplate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotW := w.String(); gotW != tt.wantW {
t.Errorf("executeTemplate() = %q, want %q", gotW, tt.wantW)
}
})
}
}