Skip to content

Commit 01429de

Browse files
authored
Merge pull request #2169 from github/api-pagination
Implement `hub api --paginate`
2 parents 1102dda + b6d9837 commit 01429de

File tree

5 files changed

+142
-30
lines changed

5 files changed

+142
-30
lines changed

commands/api.go

+83-24
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package commands
22

33
import (
4+
"bytes"
45
"fmt"
56
"io"
67
"io/ioutil"
@@ -66,12 +67,20 @@ var cmdApi = &Command{
6667
Parse response JSON and output the data in a line-based key-value format
6768
suitable for use in shell scripts.
6869
70+
--paginate
71+
Automatically request and output the next page of results until all
72+
resources have been listed. For GET requests, this follows the '<next>'
73+
resource as indicated in the "Link" response header. For GraphQL queries,
74+
this utilizes 'pageInfo' that must be present in the query; see EXAMPLES.
75+
76+
Note that multiple JSON documents will be output as a result.
77+
6978
--color[=<WHEN>]
7079
Enable colored output even if stdout is not a terminal. <WHEN> can be one
7180
of "always" (default for '--color'), "never", or "auto" (default).
7281
7382
--cache <TTL>
74-
Cache successful responses to GET requests for <TTL> seconds.
83+
Cache valid responses to GET requests for <TTL> seconds.
7584
7685
When using "graphql" as <ENDPOINT>, caching will apply to responses to POST
7786
requests as well. Just make sure to not use '--cache' for any GraphQL
@@ -101,6 +110,22 @@ var cmdApi = &Command{
101110
# perform a GraphQL query read from a file
102111
$ hub api graphql -F query=@path/to/myquery.graphql
103112
113+
# perform pagination with GraphQL
114+
$ hub api --paginate graphql -f query=''
115+
query($endCursor: String) {
116+
repositoryOwner(login: "USER") {
117+
repositories(first: 100, after: $endCursor) {
118+
nodes {
119+
nameWithOwner
120+
}
121+
pageInfo {
122+
hasNextPage
123+
endCursor
124+
}
125+
}
126+
}
127+
}''
128+
104129
## See also:
105130
106131
hub(1)
@@ -165,7 +190,8 @@ func apiCommand(cmd *Command, args *Args) {
165190
host = defHost.Host
166191
}
167192

168-
if path == "graphql" && params["query"] != nil {
193+
isGraphQL := path == "graphql"
194+
if isGraphQL && params["query"] != nil {
169195
query := params["query"].(string)
170196
query = strings.Replace(query, quote("{owner}"), quote(owner), 1)
171197
query = strings.Replace(query, quote("{repo}"), quote(repo), 1)
@@ -203,36 +229,69 @@ func apiCommand(cmd *Command, args *Args) {
203229
}
204230

205231
gh := github.NewClient(host)
206-
response, err := gh.GenericAPIRequest(method, path, body, headers, cacheTTL)
207-
utils.Check(err)
208-
209-
args.NoForward()
210232

211233
out := ui.Stdout
212234
colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color"))
213-
success := response.StatusCode < 300
214235
parseJSON := args.Flag.Bool("--flat")
236+
includeHeaders := args.Flag.Bool("--include")
237+
paginate := args.Flag.Bool("--paginate")
215238

216-
if !success {
217-
jsonType, _ := regexp.MatchString(`[/+]json(?:;|$)`, response.Header.Get("Content-Type"))
218-
parseJSON = parseJSON && jsonType
219-
}
239+
args.NoForward()
220240

221-
if args.Flag.Bool("--include") {
222-
fmt.Fprintf(out, "%s %s\r\n", response.Proto, response.Status)
223-
response.Header.Write(out)
224-
fmt.Fprintf(out, "\r\n")
225-
}
241+
requestLoop := true
242+
for requestLoop {
243+
response, err := gh.GenericAPIRequest(method, path, body, headers, cacheTTL)
244+
utils.Check(err)
245+
success := response.StatusCode < 300
226246

227-
if parseJSON {
228-
utils.JSONPath(out, response.Body, colorize)
229-
} else {
230-
io.Copy(out, response.Body)
231-
}
232-
response.Body.Close()
247+
jsonType := true
248+
if !success {
249+
jsonType, _ = regexp.MatchString(`[/+]json(?:;|$)`, response.Header.Get("Content-Type"))
250+
}
251+
252+
if includeHeaders {
253+
fmt.Fprintf(out, "%s %s\r\n", response.Proto, response.Status)
254+
response.Header.Write(out)
255+
fmt.Fprintf(out, "\r\n")
256+
}
257+
258+
endCursor := ""
259+
hasNextPage := false
260+
261+
if parseJSON && jsonType {
262+
hasNextPage, endCursor = utils.JSONPath(out, response.Body, colorize)
263+
} else if paginate && isGraphQL {
264+
bodyCopy := &bytes.Buffer{}
265+
io.Copy(out, io.TeeReader(response.Body, bodyCopy))
266+
hasNextPage, endCursor = utils.JSONPath(ioutil.Discard, bodyCopy, false)
267+
} else {
268+
io.Copy(out, response.Body)
269+
}
270+
response.Body.Close()
271+
272+
if !success {
273+
os.Exit(22)
274+
}
233275

234-
if !success {
235-
os.Exit(22)
276+
requestLoop = false
277+
if paginate {
278+
if isGraphQL && hasNextPage && endCursor != "" {
279+
if v, ok := params["variables"]; ok {
280+
variables := v.(map[string]interface{})
281+
variables["endCursor"] = endCursor
282+
} else {
283+
variables := map[string]interface{}{"endCursor": endCursor}
284+
params["variables"] = variables
285+
}
286+
requestLoop = true
287+
} else if nextLink := response.Link("next"); nextLink != "" {
288+
path = nextLink
289+
requestLoop = true
290+
}
291+
}
292+
if requestLoop && !parseJSON {
293+
fmt.Fprintf(out, "\n")
294+
}
236295
}
237296
}
238297

commands/commands.go

+1
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func (c *Command) HelpText() string {
122122
}
123123

124124
long = strings.Replace(long, "'", "`", -1)
125+
long = strings.Replace(long, "``", "'", -1)
125126
headingRe := regexp.MustCompile(`(?m)^(## .+):$`)
126127
long = headingRe.ReplaceAllString(long, "$1")
127128

features/api.feature

+40
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,46 @@ Feature: hub api
109109
{"name":"Faye"}
110110
"""
111111

112+
Scenario: Paginate REST
113+
Given the GitHub API server:
114+
"""
115+
get('/comments') {
116+
assert :per_page => "6"
117+
page = (params[:page] || 1).to_i
118+
response.headers["Link"] = %(<#{request.url}&page=#{page+1}>; rel="next") if page < 3
119+
json [{:page => page}]
120+
}
121+
"""
122+
When I successfully run `hub api --paginate comments?per_page=6`
123+
Then the output should contain exactly:
124+
"""
125+
[{"page":1}]
126+
[{"page":2}]
127+
[{"page":3}]
128+
"""
129+
130+
Scenario: Paginate GraphQL
131+
Given the GitHub API server:
132+
"""
133+
post('/graphql') {
134+
variables = params[:variables] || {}
135+
page = (variables["endCursor"] || 1).to_i
136+
json :data => {
137+
:pageInfo => {
138+
:hasNextPage => page < 3,
139+
:endCursor => (page+1).to_s
140+
}
141+
}
142+
}
143+
"""
144+
When I successfully run `hub api --paginate graphql -f query=QUERY`
145+
Then the output should contain exactly:
146+
"""
147+
{"data":{"pageInfo":{"hasNextPage":true,"endCursor":"2"}}}
148+
{"data":{"pageInfo":{"hasNextPage":true,"endCursor":"3"}}}
149+
{"data":{"pageInfo":{"hasNextPage":false,"endCursor":"4"}}}
150+
"""
151+
112152
Scenario: Avoid leaking token to a 3rd party
113153
Given the GitHub API server:
114154
"""

github/client.go

+10-2
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ func NewClient(h string) *Client {
2929
}
3030

3131
func NewClientWithHost(host *Host) *Client {
32-
return &Client{host}
32+
return &Client{Host: host}
3333
}
3434

3535
type Client struct {
36-
Host *Host
36+
Host *Host
37+
cachedClient *simpleClient
3738
}
3839

3940
func (client *Client) FetchPullRequests(project *Project, filterParams map[string]interface{}, limit int, filter func(*PullRequest) bool) (pulls []PullRequest, err error) {
@@ -936,6 +937,11 @@ func (client *Client) simpleApi() (c *simpleClient, err error) {
936937
return
937938
}
938939

940+
if client.cachedClient != nil {
941+
c = client.cachedClient
942+
return
943+
}
944+
939945
c = client.apiClient()
940946
c.PrepareRequest = func(req *http.Request) {
941947
clientDomain := normalizeHost(client.Host.Host)
@@ -947,6 +953,8 @@ func (client *Client) simpleApi() (c *simpleClient, err error) {
947953
req.Header.Set("Authorization", "token "+client.Host.AccessToken)
948954
}
949955
}
956+
957+
client.cachedClient = c
950958
return
951959
}
952960

utils/json.go

+8-4
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,7 @@ func stateKey(s *state) string {
2929
}
3030
}
3131

32-
func printValue(token json.Token) {
33-
}
34-
35-
func JSONPath(out io.Writer, src io.Reader, colorize bool) {
32+
func JSONPath(out io.Writer, src io.Reader, colorize bool) (hasNextPage bool, endCursor string) {
3633
dec := json.NewDecoder(src)
3734
dec.UseNumber()
3835

@@ -84,17 +81,24 @@ func JSONPath(out io.Writer, src io.Reader, colorize bool) {
8481
switch tt := token.(type) {
8582
case string:
8683
fmt.Fprintf(out, "%s\n", strings.Replace(tt, "\n", "\\n", -1))
84+
if strings.HasSuffix(k, ".pageInfo.endCursor") {
85+
endCursor = tt
86+
}
8787
case json.Number:
8888
fmt.Fprintf(out, "%s\n", color("0;35", tt))
8989
case nil:
9090
fmt.Fprintf(out, "\n")
9191
case bool:
9292
fmt.Fprintf(out, "%s\n", color("1;33", fmt.Sprintf("%v", tt)))
93+
if strings.HasSuffix(k, ".pageInfo.hasNextPage") {
94+
hasNextPage = tt
95+
}
9396
default:
9497
panic("unknown type")
9598
}
9699
postEmit()
97100
}
98101
}
99102
}
103+
return
100104
}

0 commit comments

Comments
 (0)