Add template functions, documentation, tests

This commit is contained in:
Mislav Marohnić 2021-03-02 20:07:04 +01:00
parent ed219ab5f3
commit bf97c6e273
4 changed files with 216 additions and 27 deletions

View file

@ -7,7 +7,6 @@ import (
"fmt"
"io"
"io/ioutil"
"math"
"net/http"
"os"
"regexp"
@ -25,7 +24,6 @@ import (
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/jsoncolor"
"github.com/mgutz/ansi"
"github.com/spf13/cobra"
)
@ -98,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 +121,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
# set a custom HTTP header
$ gh api -H 'Accept: application/vnd.github.XYZ-preview+json' ...
# 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!) {
@ -183,7 +196,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, "format", "t", "", "Format the response using a Go template")
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
}
@ -312,31 +325,12 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
}
if opts.Template != "" {
templateFuncs := map[string]interface{}{
"color": func(colorName string, input interface{}) (string, error) {
var text string
switch tt := input.(type) {
case string:
text = tt
case float64:
if math.Trunc(tt) == tt {
text = strconv.FormatFloat(tt, 'f', 0, 64)
} else {
text = strconv.FormatFloat(tt, 'f', 2, 64)
}
case nil:
text = ""
case bool:
text = fmt.Sprintf("%v", tt)
default:
return "", fmt.Errorf("cannot convert type to string: %v", tt)
}
return ansi.Color(text, colorName), nil
},
}
// TODO: reuse parsed template across pagination invocations
t := template.Must(template.New("").Funcs(templateFuncs).Parse(opts.Template))
var t *template.Template
t, err = parseTemplate(opts.Template, opts.IO.ColorEnabled())
if err != nil {
return
}
var jsonData []byte
jsonData, err = ioutil.ReadAll(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"
@ -910,3 +912,40 @@ func Test_fillPlaceholders(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())
}

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

@ -0,0 +1,96 @@
package api
import (
"fmt"
"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 utils.FuzzyAgoAbbr(now, 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 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
}

View file

@ -0,0 +1,60 @@
package api
import "testing"
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)
}
})
}
}