-
-
Save myitcv/f7da6cb073de3c48c9f88b6b95225b4c to your computer and use it in GitHub Desktop.
Exploring GraphQL nested cursors
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Set GITHUB_PAT | |
go run . cuelang/cue | |
-- main.go -- | |
// graphqlfun extracts all the discussions + comments + replies from a GitHub | |
// repository, printing the JSON-marshalled results to stdout. This is a toy | |
// program designed to explore the problems associated with nested cursors in | |
// GraphQL. graphqlfun minimises the number of calls to the GitHub GraphQL | |
// endpoint. | |
// | |
// Usage: | |
// graphqlfun OWNER/REPOSITORY | |
// | |
// Example: | |
// graphqlfun cuelang/cue | |
// | |
package main | |
import ( | |
"context" | |
"encoding/json" | |
"flag" | |
"fmt" | |
"log" | |
"os" | |
"strconv" | |
"strings" | |
"time" | |
"github.com/shurcooL/graphql" | |
"golang.org/x/oauth2" | |
) | |
func main() { | |
flag.Parse() | |
args := flag.Args() | |
if len(args) != 1 { | |
log.Fatalf("expected a single arg: the GitHub repository to query (e.g. cuelang/cue)") | |
} | |
parts := strings.Split(args[0], "/") | |
if len(parts) != 2 { | |
log.Fatalf("incorrect format for repository argument; expected owner/repo") | |
} | |
owner, repo := parts[0], parts[1] | |
src := oauth2.StaticTokenSource( | |
&oauth2.Token{AccessToken: os.Getenv("GITHUB_PAT")}, | |
) | |
httpClient := oauth2.NewClient(context.Background(), src) | |
client := graphql.NewClient("https://api.github.com/graphql", httpClient) | |
// Get discussions with as many comments and replies as possible | |
var res outer | |
{ | |
var after *graphql.String | |
for { | |
var query outer | |
err := client.Query(context.Background(), &query, map[string]interface{}{ | |
"repo": graphql.String(repo), | |
"owner": graphql.String(owner), | |
"after": after, | |
}) | |
if err != nil { | |
log.Fatalf("failed to run query: %v", err) | |
} | |
for _, disc := range query.Repository.Discussions.Edges { | |
res.Repository.Discussions.Edges = append(res.Repository.Discussions.Edges, disc) | |
after = &disc.Cursor | |
} | |
if !query.Repository.Discussions.PageInfo.HasNextPage { | |
break | |
} | |
} | |
} | |
// Ensure we have all comments for all discussions | |
{ | |
for _, disc := range res.Repository.Discussions.Edges { | |
if !disc.Node.Comments.PageInfo.HasNextPage { | |
continue | |
} | |
afterComments := &disc.Node.Comments.Edges[len(disc.Node.Comments.Edges)-1].Cursor | |
for { | |
var query comments | |
err := client.Query(context.Background(), &query, map[string]interface{}{ | |
"repo": graphql.String(repo), | |
"owner": graphql.String(owner), | |
"number": graphql.Int(disc.Node.Number), | |
"after": afterComments, | |
}) | |
if err != nil { | |
log.Fatalf("failed to run query: %v", err) | |
} | |
for _, comm := range query.Repository.Discussion.Comments.Edges { | |
disc.Node.Comments.Edges = append(disc.Node.Comments.Edges, comm) | |
afterComments = &comm.Cursor | |
} | |
if !query.Repository.Discussion.Comments.PageInfo.HasNextPage { | |
break | |
} | |
} | |
} | |
} | |
// Ensure we have all replies for all comments | |
{ | |
for _, disc := range res.Repository.Discussions.Edges { | |
for i, comm := range disc.Node.Comments.Edges { | |
var afterComment *graphql.String | |
if !comm.Node.Replies.PageInfo.HasNextPage { | |
continue | |
} | |
if i > 0 { | |
afterComment = &disc.Node.Comments.Edges[i-1].Cursor | |
} | |
afterReply := &comm.Node.Replies.Edges[len(comm.Node.Replies.Edges)-1].Cursor | |
for { | |
var query replies | |
err := client.Query(context.Background(), &query, map[string]interface{}{ | |
"repo": graphql.String(repo), | |
"owner": graphql.String(owner), | |
"number": graphql.Int(disc.Node.Number), | |
"afterComment": afterComment, | |
"afterReply": afterReply, | |
}) | |
if err != nil { | |
log.Fatalf("failed to run query: %v", err) | |
} | |
if l := len(query.Repository.Discussion.Comments.Edges); l != 1 { | |
panic(fmt.Errorf("expected a single comment; saw %v", l)) | |
} | |
rescomm := query.Repository.Discussion.Comments.Edges[0] | |
if rescomm.Node.ID != comm.Node.ID { | |
panic(fmt.Errorf("expected comment %v; got %v", comm.Node.ID, rescomm.Node.ID)) | |
} | |
for _, rep := range rescomm.Node.Replies.Edges { | |
comm.Node.Replies.Edges = append(comm.Node.Replies.Edges, rep) | |
afterReply = &rep.Cursor | |
} | |
if !rescomm.Node.Replies.PageInfo.HasNextPage { | |
break | |
} | |
} | |
} | |
} | |
} | |
fmt.Printf("%s\n", encjson(res)) | |
} | |
// outer is the query that gives us discussions + their comments + the | |
// comments' replies | |
type outer struct { | |
Repository struct { | |
ID graphql.String | |
Discussions struct { | |
PageInfo PageInfo | |
Edges []*struct { | |
Cursor graphql.String | |
Node struct { | |
discussionDetail | |
Comments struct { | |
PageInfo PageInfo | |
Edges []*struct { | |
Cursor graphql.String | |
Node struct { | |
commentDetail | |
Replies struct { | |
PageInfo PageInfo | |
Edges []*struct { | |
Cursor graphql.String | |
Node struct { | |
commentDetail | |
} | |
} | |
} `graphql:"replies(first:50)"` | |
} | |
} | |
} `graphql:"comments(first:50)"` | |
} | |
} | |
} `graphql:"discussions(first:100, after:$after, orderBy: {field: CREATED_AT, direction: ASC})"` | |
} `graphql:"repository(name: $repo, owner: $owner)"` | |
} | |
type discussionDetail struct { | |
ID graphql.String | |
Title graphql.String | |
Body graphql.String | |
Number graphql.Int | |
Author struct { | |
Login graphql.String | |
} | |
CreatedAt dateTime | |
} | |
type commentDetail struct { | |
ID graphql.String | |
Body graphql.String | |
CreatedAt dateTime | |
Author struct { | |
Login graphql.String | |
} | |
ReplyTo struct { | |
ID graphql.String | |
} | |
} | |
// comments is the query that gives us the comments + the comments' replies for | |
// a given discussion | |
type comments struct { | |
Repository struct { | |
ID graphql.String | |
Discussion struct { | |
ID graphql.String | |
Comments struct { | |
PageInfo PageInfo | |
Edges []*struct { | |
Cursor graphql.String | |
Node struct { | |
commentDetail | |
Replies struct { | |
PageInfo PageInfo | |
Edges []*struct { | |
Cursor graphql.String | |
Node struct { | |
commentDetail | |
} | |
} | |
} `graphql:"replies(first:50)"` | |
} | |
} | |
} `graphql:"comments(first:50, after:$after)"` | |
} `graphql:"discussion(number:$number)"` | |
} `graphql:"repository(name: $repo, owner: $owner)"` | |
} | |
// replies is the query that gives us the replies for a given discussion comment | |
type replies struct { | |
Repository struct { | |
ID graphql.String | |
Discussion struct { | |
ID graphql.String | |
Comments struct { | |
PageInfo PageInfo | |
Edges []*struct { | |
Cursor graphql.String | |
Node struct { | |
ID graphql.String | |
Replies struct { | |
PageInfo PageInfo | |
Edges []*struct { | |
Cursor graphql.String | |
Node struct { | |
commentDetail | |
} | |
} | |
} `graphql:"replies(first:50, after:$afterReply)"` | |
} | |
} | |
} `graphql:"comments(first:1, after:$afterComment)"` | |
} `graphql:"discussion(number:$number)"` | |
} `graphql:"repository(name: $repo, owner: $owner)"` | |
} | |
type PageInfo struct { | |
HasNextPage graphql.Boolean | |
} | |
// ********** | |
// Other util | |
// ********** | |
func encjson(v interface{}) string { | |
byts, err := json.MarshalIndent(v, "", " ") | |
if err != nil { | |
panic(err) | |
} | |
return string(byts) | |
} | |
type dateTime time.Time | |
func (d *dateTime) UnmarshalJSON(b []byte) error { | |
var timeAsString string | |
if err := json.Unmarshal(b, &timeAsString); err != nil { | |
return err | |
} | |
t, err := time.Parse(time.RFC3339, timeAsString) | |
if err != nil { | |
return fmt.Errorf("failed to parse time: %v", err) | |
} | |
*d = dateTime(t) | |
return nil | |
} | |
func (d *dateTime) MarshalJSON() ([]byte, error) { | |
t := time.Time(*d) | |
return []byte(strconv.Quote(t.Format(time.RFC3339))), nil | |
} | |
func (d dateTime) before(d2 dateTime) bool { | |
return time.Time(d).Before(time.Time(d2)) | |
} | |
-- go.mod -- | |
module myitcv.io/graphqlfun | |
go 1.15 | |
require ( | |
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a | |
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment