Merge pull request #3011 from cli/api-template
Add a template `--format` flag to api command
This commit is contained in:
commit
0aebfacd95
4 changed files with 408 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
137
pkg/cmd/api/template.go
Normal 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"
|
||||
}
|
||||
163
pkg/cmd/api/template_test.go
Normal file
163
pkg/cmd/api/template_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue