Skip to content

Commit 1609afe

Browse files
committed
Add api command
1 parent 45dec1b commit 1609afe

File tree

2 files changed

+210
-0
lines changed

2 files changed

+210
-0
lines changed

api/client.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,45 @@ func (c Client) REST(method string, p string, body io.Reader, data interface{})
207207
return nil
208208
}
209209

210+
// DirectRequest is a low-level interface to making generic API requests
211+
func (c Client) DirectRequest(method string, p string, params interface{}, headers []string) (*http.Response, error) {
212+
url := "https://api.github.com/" + p
213+
var body io.Reader
214+
var bodyIsJSON bool
215+
216+
switch pp := params.(type) {
217+
case map[string]interface{}:
218+
b, err := json.Marshal(pp)
219+
if err != nil {
220+
return nil, fmt.Errorf("error serializing parameters: %w", err)
221+
}
222+
body = bytes.NewBuffer(b)
223+
bodyIsJSON = true
224+
case io.Reader:
225+
body = pp
226+
default:
227+
return nil, fmt.Errorf("unrecognized parameters type: %v", params)
228+
}
229+
230+
req, err := http.NewRequest(method, url, body)
231+
if err != nil {
232+
return nil, err
233+
}
234+
235+
if bodyIsJSON {
236+
req.Header.Set("Content-Type", "application/json; charset=utf-8")
237+
}
238+
for _, h := range headers {
239+
idx := strings.IndexRune(h, ':')
240+
if idx == -1 {
241+
return nil, fmt.Errorf("header %q requires a value separated by ':'", h)
242+
}
243+
req.Header.Set(h[0:idx], strings.TrimSpace(h[idx+1:]))
244+
}
245+
246+
return c.http.Do(req)
247+
}
248+
210249
func handleResponse(resp *http.Response, data interface{}) error {
211250
success := resp.StatusCode >= 200 && resp.StatusCode < 300
212251

command/api.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package command
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"io/ioutil"
7+
"os"
8+
"strconv"
9+
"strings"
10+
11+
"github.com/cli/cli/api"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
func init() {
16+
RootCmd.AddCommand(makeApiCommand())
17+
}
18+
19+
type ApiOptions struct {
20+
RequestMethod string
21+
RequestMethodPassed bool
22+
RequestPath string
23+
MagicFields []string
24+
RawFields []string
25+
RequestHeaders []string
26+
ShowResponseHeaders bool
27+
}
28+
29+
func makeApiCommand() *cobra.Command {
30+
opts := ApiOptions{}
31+
cmd := &cobra.Command{
32+
Use: "api <endpoint>",
33+
Short: "Make an authenticated GitHub API request",
34+
Long: `Makes an authenticated HTTP request to the GitHub API and prints the response.
35+
36+
The <endpoint> argument should either be a path of a GitHub API v3 endpoint, or
37+
"graphql" to access the GitHub API v4.
38+
39+
The default HTTP request method is "GET" normally and "POST" if any parameters
40+
were added. Override the method with '--method'.
41+
42+
Pass one or more '--raw-field' values in "<key>=<value>" format to add
43+
JSON-encoded string parameters to the POST body.
44+
45+
The '--field' flag behaves like '--raw-field' with magic type conversion based
46+
on the format of the value:
47+
48+
- literal values "true", "false", "null", and integer numbers get converted to
49+
appropriate JSON types;
50+
- if the value starts with "@", the rest of the value is interpreted as a
51+
filename to read the value from. Pass "-" to read from standard input.
52+
`,
53+
Args: cobra.ExactArgs(1),
54+
RunE: func(c *cobra.Command, args []string) error {
55+
opts.RequestPath = args[0]
56+
opts.RequestMethodPassed = c.Flags().Changed("method")
57+
58+
ctx := contextForCommand(c)
59+
client, err := apiClientForContext(ctx)
60+
if err != nil {
61+
return err
62+
}
63+
64+
return apiRun(&opts, client)
65+
},
66+
}
67+
68+
cmd.Flags().StringVarP(&opts.RequestMethod, "method", "X", "GET", "The HTTP method for the request")
69+
cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a parameter of inferred type")
70+
cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter")
71+
cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add an additional HTTP request header")
72+
cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output")
73+
return cmd
74+
}
75+
76+
func apiRun(opts *ApiOptions, client *api.Client) error {
77+
params := make(map[string]interface{})
78+
for _, f := range opts.RawFields {
79+
key, value, err := parseField(f)
80+
if err != nil {
81+
return err
82+
}
83+
params[key] = value
84+
}
85+
for _, f := range opts.MagicFields {
86+
key, strValue, err := parseField(f)
87+
if err != nil {
88+
return err
89+
}
90+
value, err := magicFieldValue(strValue)
91+
if err != nil {
92+
return fmt.Errorf("error parsing %q value: %w", key, err)
93+
}
94+
params[key] = value
95+
}
96+
97+
method := opts.RequestMethod
98+
if len(params) > 0 && !opts.RequestMethodPassed {
99+
method = "POST"
100+
}
101+
102+
resp, err := client.DirectRequest(method, opts.RequestPath, params, opts.RequestHeaders)
103+
if err != nil {
104+
return err
105+
}
106+
107+
if opts.ShowResponseHeaders {
108+
for name, vals := range resp.Header {
109+
fmt.Printf("%s: %s\r\n", name, strings.Join(vals, ", "))
110+
}
111+
fmt.Print("\r\n")
112+
}
113+
114+
if resp.StatusCode == 204 {
115+
return nil
116+
}
117+
defer resp.Body.Close()
118+
119+
// TODO: make stdout configurable for tests
120+
_, err = io.Copy(os.Stdout, resp.Body)
121+
if err != nil {
122+
return err
123+
}
124+
125+
return nil
126+
}
127+
128+
func parseField(f string) (string, string, error) {
129+
idx := strings.IndexRune(f, '=')
130+
if idx == -1 {
131+
return f, "", fmt.Errorf("field %q requires a value separated by an '=' sign", f)
132+
}
133+
return f[0:idx], f[idx+1:], nil
134+
}
135+
136+
func magicFieldValue(v string) (interface{}, error) {
137+
if strings.HasPrefix(v, "@") {
138+
return readUserFile(v[1:])
139+
}
140+
141+
if n, err := strconv.Atoi(v); err != nil {
142+
return n, nil
143+
}
144+
145+
switch v {
146+
case "true":
147+
return true, nil
148+
case "false":
149+
return false, nil
150+
case "null":
151+
return nil, nil
152+
default:
153+
return v, nil
154+
}
155+
}
156+
157+
func readUserFile(fn string) ([]byte, error) {
158+
var r io.ReadCloser
159+
if fn == "-" {
160+
// TODO: make stdin configurable for tests
161+
r = os.Stdin
162+
} else {
163+
var err error
164+
r, err = os.Open(fn)
165+
if err != nil {
166+
return nil, err
167+
}
168+
defer r.Close()
169+
}
170+
return ioutil.ReadAll(r)
171+
}

0 commit comments

Comments
 (0)