X Tutup
Skip to content

Commit 7907def

Browse files
committed
api command: add support for REST pagination
1 parent 7b225bf commit 7907def

File tree

2 files changed

+147
-3
lines changed

2 files changed

+147
-3
lines changed

pkg/cmd/api/api.go

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"io/ioutil"
@@ -32,6 +33,7 @@ type ApiOptions struct {
3233
RawFields []string
3334
RequestHeaders []string
3435
ShowResponseHeaders bool
36+
Paginate bool
3537

3638
HttpClient func() (*http.Client, error)
3739
BaseRepo func() (ghrepo.Interface, error)
@@ -93,6 +95,13 @@ Pass "-" to read from standard input. In this mode, parameters specified via
9395
opts.RequestPath = args[0]
9496
opts.RequestMethodPassed = c.Flags().Changed("method")
9597

98+
if opts.Paginate && !strings.EqualFold(opts.RequestMethod, "GET") && opts.RequestPath != "graphql" {
99+
return &cmdutil.FlagError{Err: errors.New(`the '--paginate' option is not supported for non-GET requests`)}
100+
}
101+
if opts.Paginate && opts.RequestInputFile != "" {
102+
return &cmdutil.FlagError{Err: errors.New(`the '--paginate' option is not supported with '--input'`)}
103+
}
104+
96105
if runF != nil {
97106
return runF(&opts)
98107
}
@@ -105,6 +114,7 @@ Pass "-" to read from standard input. In this mode, parameters specified via
105114
cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter")
106115
cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add an additional HTTP request header")
107116
cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output")
117+
cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
108118
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The file to use as body for the HTTP request")
109119
return cmd
110120
}
@@ -145,11 +155,46 @@ func apiRun(opts *ApiOptions) error {
145155
return err
146156
}
147157

148-
resp, err := httpRequest(httpClient, method, requestPath, requestBody, requestHeaders)
149-
if err != nil {
150-
return err
158+
hasNextPage := true
159+
for hasNextPage {
160+
resp, err := httpRequest(httpClient, method, requestPath, requestBody, requestHeaders)
161+
if err != nil {
162+
return err
163+
}
164+
165+
err = processResponse(resp, opts)
166+
if err != nil {
167+
return err
168+
}
169+
170+
if !opts.Paginate {
171+
break
172+
}
173+
requestPath, hasNextPage = findNextPage(resp)
174+
175+
if hasNextPage && opts.ShowResponseHeaders {
176+
fmt.Fprint(opts.IO.Out, "\n")
177+
}
151178
}
152179

180+
return nil
181+
}
182+
183+
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
184+
185+
func findNextPage(resp *http.Response) (string, bool) {
186+
for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) {
187+
if len(m) < 2 {
188+
continue
189+
}
190+
if m[2] == "next" {
191+
return m[1], true
192+
}
193+
}
194+
return "", false
195+
}
196+
197+
func processResponse(resp *http.Response, opts *ApiOptions) error {
153198
if opts.ShowResponseHeaders {
154199
fmt.Fprintln(opts.IO.Out, resp.Proto, resp.Status)
155200
printHeaders(opts.IO.Out, resp.Header, opts.IO.ColorEnabled())
@@ -164,6 +209,7 @@ func apiRun(opts *ApiOptions) error {
164209

165210
isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type"))
166211

212+
var err error
167213
var serverError string
168214
if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) {
169215
responseBody, serverError, err = parseErrorResponse(responseBody, resp.StatusCode)

pkg/cmd/api/api_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func Test_NewCmdApi(t *testing.T) {
3636
MagicFields: []string(nil),
3737
RequestHeaders: []string(nil),
3838
ShowResponseHeaders: false,
39+
Paginate: false,
3940
},
4041
wantsErr: false,
4142
},
@@ -51,6 +52,7 @@ func Test_NewCmdApi(t *testing.T) {
5152
MagicFields: []string(nil),
5253
RequestHeaders: []string(nil),
5354
ShowResponseHeaders: false,
55+
Paginate: false,
5456
},
5557
wantsErr: false,
5658
},
@@ -66,6 +68,7 @@ func Test_NewCmdApi(t *testing.T) {
6668
MagicFields: []string{"body=@file.txt"},
6769
RequestHeaders: []string(nil),
6870
ShowResponseHeaders: false,
71+
Paginate: false,
6972
},
7073
wantsErr: false,
7174
},
@@ -81,9 +84,52 @@ func Test_NewCmdApi(t *testing.T) {
8184
MagicFields: []string(nil),
8285
RequestHeaders: []string{"accept: text/plain"},
8386
ShowResponseHeaders: true,
87+
Paginate: false,
8488
},
8589
wantsErr: false,
8690
},
91+
{
92+
name: "with pagination",
93+
cli: "repos/OWNER/REPO/issues --paginate",
94+
wants: ApiOptions{
95+
RequestMethod: "GET",
96+
RequestMethodPassed: false,
97+
RequestPath: "repos/OWNER/REPO/issues",
98+
RequestInputFile: "",
99+
RawFields: []string(nil),
100+
MagicFields: []string(nil),
101+
RequestHeaders: []string(nil),
102+
ShowResponseHeaders: false,
103+
Paginate: true,
104+
},
105+
wantsErr: false,
106+
},
107+
{
108+
name: "POST pagination",
109+
cli: "-XPOST repos/OWNER/REPO/issues --paginate",
110+
wantsErr: true,
111+
},
112+
{
113+
name: "GraphQL pagination",
114+
cli: "-XPOST graphql --paginate",
115+
wants: ApiOptions{
116+
RequestMethod: "POST",
117+
RequestMethodPassed: true,
118+
RequestPath: "graphql",
119+
RequestInputFile: "",
120+
RawFields: []string(nil),
121+
MagicFields: []string(nil),
122+
RequestHeaders: []string(nil),
123+
ShowResponseHeaders: false,
124+
Paginate: true,
125+
},
126+
wantsErr: false,
127+
},
128+
{
129+
name: "input pagination",
130+
cli: "--input repos/OWNER/REPO/issues --paginate",
131+
wantsErr: true,
132+
},
87133
{
88134
name: "with request body from file",
89135
cli: "user --input myfile",
@@ -96,6 +142,7 @@ func Test_NewCmdApi(t *testing.T) {
96142
MagicFields: []string(nil),
97143
RequestHeaders: []string(nil),
98144
ShowResponseHeaders: false,
145+
Paginate: false,
99146
},
100147
wantsErr: false,
101148
},
@@ -246,6 +293,57 @@ func Test_apiRun(t *testing.T) {
246293
}
247294
}
248295

296+
func Test_apiRun_pagination(t *testing.T) {
297+
io, _, stdout, stderr := iostreams.Test()
298+
299+
requestCount := 0
300+
responses := []*http.Response{
301+
{
302+
StatusCode: 200,
303+
Body: ioutil.NopCloser(bytes.NewBufferString(`{"page":1}`)),
304+
Header: http.Header{
305+
"Link": []string{`<https://api.github.com/repositories/1227/issues?page=2>; rel="next", <https://api.github.com/repositories/1227/issues?page=3>; rel="last"`},
306+
},
307+
},
308+
{
309+
StatusCode: 200,
310+
Body: ioutil.NopCloser(bytes.NewBufferString(`{"page":2}`)),
311+
Header: http.Header{
312+
"Link": []string{`<https://api.github.com/repositories/1227/issues?page=3>; rel="next", <https://api.github.com/repositories/1227/issues?page=3>; rel="last"`},
313+
},
314+
},
315+
{
316+
StatusCode: 200,
317+
Body: ioutil.NopCloser(bytes.NewBufferString(`{"page":3}`)),
318+
Header: http.Header{},
319+
},
320+
}
321+
322+
options := ApiOptions{
323+
IO: io,
324+
HttpClient: func() (*http.Client, error) {
325+
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
326+
resp := responses[requestCount]
327+
resp.Request = req
328+
requestCount++
329+
return resp, nil
330+
}
331+
return &http.Client{Transport: tr}, nil
332+
},
333+
334+
Paginate: true,
335+
}
336+
337+
err := apiRun(&options)
338+
assert.NoError(t, err)
339+
340+
assert.Equal(t, `{"page":1}{"page":2}{"page":3}`, stdout.String(), "stdout")
341+
assert.Equal(t, "", stderr.String(), "stderr")
342+
343+
assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=2", responses[1].Request.URL.String())
344+
assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=3", responses[2].Request.URL.String())
345+
}
346+
249347
func Test_apiRun_inputFile(t *testing.T) {
250348
tests := []struct {
251349
name string

0 commit comments

Comments
 (0)
X Tutup