Skip to content

Commit

Permalink
Merge pull request cli#1165 from cli/api-repo-placeholders
Browse files Browse the repository at this point in the history
api command: support `{owner}` and `{repo}` placeholders
  • Loading branch information
Nate Smith authored Jun 11, 2020
2 parents 420d527 + 3f6d0bf commit db74ea0
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 15 deletions.
7 changes: 7 additions & 0 deletions command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,22 @@ func init() {
HttpClient: func() (*http.Client, error) {
token := os.Getenv("GITHUB_TOKEN")
if len(token) == 0 {
// TODO: decouple from `context`
ctx := context.New()
var err error
// TODO: pass IOStreams to this so that the auth flow knows if it's interactive or not
token, err = ctx.AuthToken()
if err != nil {
return nil, err
}
}
return httpClient(token), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
// TODO: decouple from `context`
ctx := context.New()
return ctx.BaseRepo()
},
}
RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil))
}
Expand Down
66 changes: 59 additions & 7 deletions pkg/cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"strconv"
"strings"

"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/jsoncolor"
Expand All @@ -32,40 +34,60 @@ type ApiOptions struct {
ShowResponseHeaders bool

HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
}

func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command {
opts := ApiOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
BaseRepo: f.BaseRepo,
}

cmd := &cobra.Command{
Use: "api <endpoint>",
Short: "Make an authenticated GitHub API request",
Long: `Makes an authenticated HTTP request to the GitHub API and prints the response.
The <endpoint> argument should either be a path of a GitHub API v3 endpoint, or
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
"graphql" to access the GitHub API v4.
Placeholder values ":owner" and ":repo" in the endpoint argument will get replaced
with values from the repository of the current directory.
The default HTTP request method is "GET" normally and "POST" if any parameters
were added. Override the method with '--method'.
Pass one or more '--raw-field' values in "<key>=<value>" format to add
Pass one or more '--raw-field' values in "key=value" format to add
JSON-encoded string parameters to the POST body.
The '--field' flag behaves like '--raw-field' with magic type conversion based
on the format of the value:
- literal values "true", "false", "null", and integer numbers get converted to
appropriate JSON types;
- placeholder values ":owner" and ":repo" get populated with values from the
repository of the current directory;
- if the value starts with "@", the rest of the value is interpreted as a
filename to read the value from. Pass "-" to read from standard input.
Raw request body may be passed from the outside via a file specified by '--input'.
Pass "-" to read from standard input. In this mode, parameters specified via
'--field' flags are serialized into URL query parameters.
`,
Example: heredoc.Doc(`
$ gh api repos/:owner/:repo/releases
$ gh api graphql -F owner=':owner' -F name=':repo' -f query='
query($name: String!, $owner: String!) {
repository(owner: $owner, name: $name) {
releases(last: 3) {
nodes { tagName }
}
}
}
'
`),
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.RequestPath = args[0]
Expand Down Expand Up @@ -93,8 +115,11 @@ func apiRun(opts *ApiOptions) error {
return err
}

requestPath, err := fillPlaceholders(opts.RequestPath, opts)
if err != nil {
return fmt.Errorf("unable to expand placeholder in path: %w", err)
}
method := opts.RequestMethod
requestPath := opts.RequestPath
requestHeaders := opts.RequestHeaders
var requestBody interface{} = params

Expand Down Expand Up @@ -170,6 +195,33 @@ func apiRun(opts *ApiOptions) error {
return nil
}

var placeholderRE = regexp.MustCompile(`\:(owner|repo)\b`)

// fillPlaceholders populates `:owner` and `:repo` placeholders with values from the current repository
func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
if !placeholderRE.MatchString(value) {
return value, nil
}

baseRepo, err := opts.BaseRepo()
if err != nil {
return value, err
}

value = placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
switch m {
case ":owner":
return baseRepo.RepoOwner()
case ":repo":
return baseRepo.RepoName()
default:
panic(fmt.Sprintf("invalid placeholder: %q", m))
}
})

return value, nil
}

func printHeaders(w io.Writer, headers http.Header, colorize bool) {
var names []string
for name := range headers {
Expand Down Expand Up @@ -204,7 +256,7 @@ func parseFields(opts *ApiOptions) (map[string]interface{}, error) {
if err != nil {
return params, err
}
value, err := magicFieldValue(strValue, opts.IO.In)
value, err := magicFieldValue(strValue, opts)
if err != nil {
return params, fmt.Errorf("error parsing %q value: %w", key, err)
}
Expand All @@ -221,9 +273,9 @@ func parseField(f string) (string, string, error) {
return f[0:idx], f[idx+1:], nil
}

func magicFieldValue(v string, stdin io.ReadCloser) (interface{}, error) {
func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) {
if strings.HasPrefix(v, "@") {
return readUserFile(v[1:], stdin)
return readUserFile(v[1:], opts.IO.In)
}

if n, err := strconv.Atoi(v); err == nil {
Expand All @@ -238,7 +290,7 @@ func magicFieldValue(v string, stdin io.ReadCloser) (interface{}, error) {
case "null":
return nil, nil
default:
return v, nil
return fillPlaceholders(v, opts)
}
}

Expand Down
99 changes: 91 additions & 8 deletions pkg/cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ package api
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"testing"

"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
Expand Down Expand Up @@ -366,9 +366,11 @@ func Test_magicFieldValue(t *testing.T) {
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })

io, _, _, _ := iostreams.Test()

type args struct {
v string
stdin io.ReadCloser
v string
opts *ApiOptions
}
tests := []struct {
name string
Expand Down Expand Up @@ -401,21 +403,41 @@ func Test_magicFieldValue(t *testing.T) {
wantErr: false,
},
{
name: "file",
args: args{v: "@" + f.Name()},
name: "placeholder",
args: args{
v: ":owner",
opts: &ApiOptions{
IO: io,
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
},
},
want: "hubot",
wantErr: false,
},
{
name: "file",
args: args{
v: "@" + f.Name(),
opts: &ApiOptions{IO: io},
},
want: []byte("file contents"),
wantErr: false,
},
{
name: "file error",
args: args{v: "@"},
name: "file error",
args: args{
v: "@",
opts: &ApiOptions{IO: io},
},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := magicFieldValue(tt.args.v, tt.args.stdin)
got, err := magicFieldValue(tt.args.v, tt.args.opts)
if (err != nil) != tt.wantErr {
t.Errorf("magicFieldValue() error = %v, wantErr %v", err, tt.wantErr)
return
Expand Down Expand Up @@ -451,3 +473,64 @@ func Test_openUserFile(t *testing.T) {
assert.Equal(t, int64(13), length)
assert.Equal(t, "file contents", string(fb))
}

func Test_fillPlaceholders(t *testing.T) {
type args struct {
value string
opts *ApiOptions
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "no changes",
args: args{
value: "repos/owner/repo/releases",
opts: &ApiOptions{
BaseRepo: nil,
},
},
want: "repos/owner/repo/releases",
wantErr: false,
},
{
name: "has substitutes",
args: args{
value: "repos/:owner/:repo/releases",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
},
},
want: "repos/hubot/robot-uprising/releases",
wantErr: false,
},
{
name: "no greedy substitutes",
args: args{
value: ":ownership/:repository",
opts: &ApiOptions{
BaseRepo: nil,
},
},
want: ":ownership/:repository",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := fillPlaceholders(tt.args.value, tt.args.opts)
if (err != nil) != tt.wantErr {
t.Errorf("fillPlaceholders() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("fillPlaceholders() got = %v, want %v", got, tt.want)
}
})
}
}
2 changes: 2 additions & 0 deletions pkg/cmdutil/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package cmdutil
import (
"net/http"

"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/iostreams"
)

type Factory struct {
IOStreams *iostreams.IOStreams
HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
}

0 comments on commit db74ea0

Please sign in to comment.