Add template functions, documentation, tests
This commit is contained in:
parent
ed219ab5f3
commit
bf97c6e273
4 changed files with 216 additions and 27 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
96
pkg/cmd/api/template.go
Normal 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
|
||||
}
|
||||
60
pkg/cmd/api/template_test.go
Normal file
60
pkg/cmd/api/template_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue