From 1a7f803241123d36c9ce1ab3893b498fe1d71e89 Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Tue, 17 May 2022 00:07:00 -0700 Subject: [PATCH] Merge JSON responses from `gh api` Fixes #1268 --- go.mod | 1 + go.sum | 3 + pkg/cmd/api/api.go | 155 +++++++++++++++++----------- pkg/cmd/api/api_test.go | 178 +++++++++++++++++++++----------- pkg/cmd/api/pagination.go | 44 +++++++- pkg/cmd/api/pagination_test.go | 179 ++++++++++++++++++++++++++++++--- 6 files changed, 425 insertions(+), 135 deletions(-) diff --git a/go.mod b/go.mod index b2bbb61bd49..7f425202ed1 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.3.0 github.com/henvic/httpretty v0.0.6 + github.com/imdario/mergo v0.3.13 github.com/joho/godotenv v1.4.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mattn/go-colorable v0.1.13 diff --git a/go.sum b/go.sum index 97cf6008c20..746dafcdc8d 100644 --- a/go.sum +++ b/go.sum @@ -168,6 +168,8 @@ github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= +github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/itchyny/gojq v0.12.8 h1:Zxcwq8w4IeR8JJYEtoG2MWJZUv0RGY6QqJcO1cqV8+A= @@ -534,6 +536,7 @@ gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index eac761be146..76f44345393 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -304,23 +304,62 @@ func apiRun(opts *ApiOptions) error { return err } + var resp *http.Response + var responseBody io.ReadSeeker + var pages interface{} + var serverError string + + isJSONRegexp := regexp.MustCompile(`[/+]json(;|$)`) + isJSON := false hasNextPage := true + for hasNextPage { - resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders) + resp, err = httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders) if err != nil { return err } - endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, &tmpl) + if resp.StatusCode == 204 { + return nil + } + + b, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { return err } + responseBody = bytes.NewReader(b) + + isJSON = isJSONRegexp.MatchString(resp.Header.Get("Content-Type")) + if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) { + serverError, err = parseErrorResponse(responseBody, resp.StatusCode) + if err != nil { + return err + } + } + + err = processServerError(serverError, resp, opts) + if err != nil { + // Print the server error message before exiting + _ = printResponse(responseBody, opts, isJSON, serverError, bodyWriter, &tmpl) + return err + } + if !opts.Paginate { break } + if isJSON { + err = mergeJSON(&pages, responseBody) + if err != nil { + return err + } + } + if isGraphQL { + endCursor := findEndCursor(responseBody) hasNextPage = endCursor != "" if hasNextPage { params["endCursor"] = endCursor @@ -329,43 +368,55 @@ func apiRun(opts *ApiOptions) error { requestPath, hasNextPage = findNextPage(resp) requestBody = nil // prevent repeating GET parameters } + } - if hasNextPage && opts.ShowResponseHeaders { - fmt.Fprint(opts.IO.Out, "\n") + if opts.Paginate && isJSON { + var buf []byte + buf, err = json.Marshal(pages) + if err != nil { + return err } - } - return tmpl.Flush() -} + responseBody = bytes.NewReader(buf) + } -func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template) (endCursor string, err error) { if opts.ShowResponseHeaders { fmt.Fprintln(headersWriter, resp.Proto, resp.Status) printHeaders(headersWriter, resp.Header, opts.IO.ColorEnabled()) fmt.Fprint(headersWriter, "\r\n") } - if resp.StatusCode == 204 { - return + err = printResponse(responseBody, opts, isJSON, serverError, bodyWriter, &tmpl) + if err != nil { + return err } - var responseBody io.Reader = resp.Body - defer resp.Body.Close() - isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type")) + return tmpl.Flush() +} - var serverError string - if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) { - responseBody, serverError, err = parseErrorResponse(responseBody, resp.StatusCode) - if err != nil { - return +func processServerError(serverError string, resp *http.Response, opts *ApiOptions) error { + if serverError == "" && resp.StatusCode > 299 { + serverError = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + + if serverError != "" { + fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError) + if msg := api.ScopesSuggestion(resp); msg != "" { + fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", msg) } + if u := factory.SSOURL(); u != "" { + fmt.Fprintf(opts.IO.ErrOut, "Authorize in your web browser: %s\n", u) + } + return cmdutil.SilentError } - var bodyCopy *bytes.Buffer - isGraphQLPaginate := isJSON && resp.StatusCode == 200 && opts.Paginate && opts.RequestPath == "graphql" - if isGraphQLPaginate { - bodyCopy = &bytes.Buffer{} - responseBody = io.TeeReader(responseBody, bodyCopy) + return nil +} + +func printResponse(responseBody io.ReadSeeker, opts *ApiOptions, isJSON bool, serverError string, bodyWriter io.Writer, template *template.Template) (err error) { + _, err = responseBody.Seek(0, io.SeekStart) + if err != nil { + return } if opts.FilterOutput != "" && serverError == "" { @@ -384,29 +435,6 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW } else { _, err = io.Copy(bodyWriter, responseBody) } - if err != nil { - return - } - - if serverError == "" && resp.StatusCode > 299 { - serverError = fmt.Sprintf("HTTP %d", resp.StatusCode) - } - if serverError != "" { - fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError) - if msg := api.ScopesSuggestion(resp); msg != "" { - fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", msg) - } - if u := factory.SSOURL(); u != "" { - fmt.Fprintf(opts.IO.ErrOut, "Authorize in your web browser: %s\n", u) - } - err = cmdutil.SilentError - return - } - - if isGraphQLPaginate { - endCursor = findEndCursor(bodyCopy) - } - return } @@ -537,46 +565,51 @@ func openUserFile(fn string, stdin io.ReadCloser) (io.ReadCloser, int64, error) return r, s.Size(), nil } -func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) { - bodyCopy := &bytes.Buffer{} - b, err := io.ReadAll(io.TeeReader(r, bodyCopy)) +func parseErrorResponse(responseBody io.ReadSeeker, statusCode int) (string, error) { + _, err := responseBody.Seek(0, io.SeekStart) if err != nil { - return r, "", err + return "", err } var parsedBody struct { Message string Errors json.RawMessage } + + b, err := io.ReadAll(responseBody) + if err != nil { + return "", err + } + err = json.Unmarshal(b, &parsedBody) if err != nil { - return bodyCopy, "", err + return "", err } if len(parsedBody.Errors) > 0 && parsedBody.Errors[0] == '"' { var stringError string if err := json.Unmarshal(parsedBody.Errors, &stringError); err != nil { - return bodyCopy, "", err + return "", err } if stringError != "" { if parsedBody.Message != "" { - return bodyCopy, fmt.Sprintf("%s (%s)", stringError, parsedBody.Message), nil + return fmt.Sprintf("%s (%s)", stringError, parsedBody.Message), nil } - return bodyCopy, stringError, nil + return stringError, nil } } if parsedBody.Message != "" { - return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil + return fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil } if len(parsedBody.Errors) == 0 || parsedBody.Errors[0] != '[' { - return bodyCopy, "", nil + return "", nil } var errorObjects []json.RawMessage if err := json.Unmarshal(parsedBody.Errors, &errorObjects); err != nil { - return bodyCopy, "", err + return "", err } var objectError struct { @@ -590,24 +623,24 @@ func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) if rawErr[0] == '{' { err := json.Unmarshal(rawErr, &objectError) if err != nil { - return bodyCopy, "", err + return "", err } errors = append(errors, objectError.Message) } else if rawErr[0] == '"' { var stringError string err := json.Unmarshal(rawErr, &stringError) if err != nil { - return bodyCopy, "", err + return "", err } errors = append(errors, stringError) } } if len(errors) > 0 { - return bodyCopy, strings.Join(errors, "\n"), nil + return strings.Join(errors, "\n"), nil } - return bodyCopy, "", nil + return "", nil } func previewNamesToMIMETypes(names []string) string { diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index 9056e1cf7e4..ebbf8865719 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "path/filepath" - "strings" "testing" "time" @@ -18,7 +17,6 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/go-gh/pkg/template" "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -490,6 +488,34 @@ func Test_apiRun(t *testing.T) { stdout: "not a cat", stderr: ``, }, + { + name: "output template with range", + options: ApiOptions{ + Template: `{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " }}){{"\n"}}{{end}}`, + }, + httpResponse: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`[ + { + "title": "First title", + "labels": [{"name":"bug"}, {"name":"help wanted"}] + }, + { + "title": "Second but not last" + }, + { + "title": "Alas, tis' the end", + "labels": [{}, {"name":"feature"}] + } + ]`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, + stdout: heredoc.Doc(` + First title (bug, help wanted) + Second but not last () + Alas, tis' the end (, feature) + `), + }, { name: "output template when REST error", options: ApiOptions{ @@ -571,22 +597,98 @@ func Test_apiRun_paginationREST(t *testing.T) { responses := []*http.Response{ { StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"page":1}`)), + Body: io.NopCloser(bytes.NewBufferString(`[{"page":1}]`)), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Link": []string{`; rel="next", ; rel="last"`}, + }, + }, + { + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`[{"page":2}]`)), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "Link": []string{`; rel="next", ; rel="last"`}, + }, + }, + { + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`[{"page":3}]`)), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, + } + + options := ApiOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + var tr roundTripper = func(req *http.Request) (*http.Response, error) { + resp := responses[requestCount] + resp.Request = req + requestCount++ + return resp, nil + } + return &http.Client{Transport: tr}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + + RequestMethod: "GET", + RequestMethodPassed: true, + RequestPath: "issues", + Paginate: true, + RawFields: []string{"per_page=50", "page=1"}, + } + + err := apiRun(&options) + assert.NoError(t, err) + + assert.Equal(t, `[{"page":1},{"page":2},{"page":3}]`, stdout.String(), "stdout") + assert.Equal(t, "", stderr.String(), "stderr") + + assert.Equal(t, "https://api.github.com/issues?page=1&per_page=50", responses[0].Request.URL.String()) + assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=2", responses[1].Request.URL.String()) + assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=3", responses[2].Request.URL.String()) +} + +func Test_apiRun_paginationREST_with_headers(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + + requestCount := 0 + responses := []*http.Response{ + { + Proto: "HTTP/1.1", + Status: "200 OK", + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`[{"page":1}]`)), Header: http.Header{ - "Link": []string{`; rel="next", ; rel="last"`}, + "Content-Type": []string{"application/json"}, + "Link": []string{`; rel="next", ; rel="last"`}, + "X-Github-Request-Id": []string{"1"}, }, }, { + Proto: "HTTP/1.1", + Status: "200 OK", StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"page":2}`)), + Body: io.NopCloser(bytes.NewBufferString(`[{"page":2}]`)), Header: http.Header{ - "Link": []string{`; rel="next", ; rel="last"`}, + "Content-Type": []string{"application/json"}, + "Link": []string{`; rel="next", ; rel="last"`}, + "X-Github-Request-Id": []string{"2"}, }, }, { + Proto: "HTTP/1.1", + Status: "200 OK", StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"page":3}`)), - Header: http.Header{}, + Body: io.NopCloser(bytes.NewBufferString(`[{"page":3}]`)), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Github-Request-Id": []string{"3"}, + }, }, } @@ -610,12 +712,13 @@ func Test_apiRun_paginationREST(t *testing.T) { RequestPath: "issues", Paginate: true, RawFields: []string{"per_page=50", "page=1"}, + ShowResponseHeaders: true, } err := apiRun(&options) assert.NoError(t, err) - assert.Equal(t, `{"page":1}{"page":2}{"page":3}`, stdout.String(), "stdout") + assert.Equal(t, "HTTP/1.1 200 OK\nContent-Type: application/json\r\nX-Github-Request-Id: 3\r\n\r\n[{\"page\":1},{\"page\":2},{\"page\":3}]", stdout.String(), "stdout") assert.Equal(t, "", stderr.String(), "stderr") assert.Equal(t, "https://api.github.com/issues?page=1&per_page=50", responses[0].Request.URL.String()) @@ -766,7 +869,8 @@ func Test_apiRun_paginated_template(t *testing.T) { RequestPath: "graphql", Paginate: true, // test that templates executed per page properly render a table. - Template: `{{range .data.nodes}}{{tablerow .page .caption}}{{end}}`, + // use explicit {{tablerender}} to assert all pages are rendered together when paginating. + Template: `{{range .data.nodes}}{{tablerow .page .caption}}{{end}}{{tablerender}}`, } err := apiRun(&options) @@ -1271,50 +1375,6 @@ func Test_previewNamesToMIMETypes(t *testing.T) { } } -func Test_processResponse_template(t *testing.T) { - ios, _, stdout, stderr := iostreams.Test() - - resp := http.Response{ - StatusCode: 200, - Header: map[string][]string{ - "Content-Type": {"application/json"}, - }, - Body: io.NopCloser(strings.NewReader(`[ - { - "title": "First title", - "labels": [{"name":"bug"}, {"name":"help wanted"}] - }, - { - "title": "Second but not last" - }, - { - "title": "Alas, tis' the end", - "labels": [{}, {"name":"feature"}] - } - ]`)), - } - - opts := ApiOptions{ - IO: ios, - Template: `{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " }}){{"\n"}}{{end}}`, - } - - tmpl := template.New(ios.Out, ios.TerminalWidth(), ios.ColorEnabled()) - err := tmpl.Parse(opts.Template) - require.NoError(t, err) - _, err = processResponse(&resp, &opts, ios.Out, io.Discard, &tmpl) - require.NoError(t, err) - err = tmpl.Flush() - require.NoError(t, err) - - assert.Equal(t, heredoc.Doc(` - First title (bug, help wanted) - Second but not last () - Alas, tis' the end (, feature) - `), stdout.String()) - assert.Equal(t, "", stderr.String()) -} - func Test_parseErrorResponse(t *testing.T) { type args struct { input string @@ -1383,15 +1443,13 @@ func Test_parseErrorResponse(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, got1, err := parseErrorResponse(strings.NewReader(tt.args.input), tt.args.statusCode) + responseBody := bytes.NewReader([]byte(tt.args.input)) + got, err := parseErrorResponse(responseBody, tt.args.statusCode) if (err != nil) != tt.wantErr { t.Errorf("parseErrorResponse() error = %v, wantErr %v", err, tt.wantErr) } - if gotString, _ := io.ReadAll(got); tt.args.input != string(gotString) { - t.Errorf("parseErrorResponse() got = %q, want %q", string(gotString), tt.args.input) - } - if got1 != tt.wantErrMsg { - t.Errorf("parseErrorResponse() got1 = %q, want %q", got1, tt.wantErrMsg) + if got != tt.wantErrMsg { + t.Errorf("parseErrorResponse() got1 = %q, want %q", got, tt.wantErrMsg) } }) } diff --git a/pkg/cmd/api/pagination.go b/pkg/cmd/api/pagination.go index 65d816480e4..fdce0b7387d 100644 --- a/pkg/cmd/api/pagination.go +++ b/pkg/cmd/api/pagination.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "encoding/json" "fmt" "io" @@ -8,6 +9,8 @@ import ( "net/url" "regexp" "strings" + + "github.com/imdario/mergo" ) var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) @@ -21,8 +24,13 @@ func findNextPage(resp *http.Response) (string, bool) { return "", false } -func findEndCursor(r io.Reader) string { - dec := json.NewDecoder(r) +func findEndCursor(responseBody io.ReadSeeker) string { + _, err := responseBody.Seek(0, io.SeekStart) + if err != nil { + return "" + } + + dec := json.NewDecoder(responseBody) var idx int var stack []json.Delim @@ -106,3 +114,35 @@ func addPerPage(p string, perPage int, params map[string]interface{}) string { return fmt.Sprintf("%s%sper_page=%d", p, sep, perPage) } + +func mergeJSON(v *interface{}, responseBody io.ReadSeeker) error { + _, err := responseBody.Seek(0, io.SeekStart) + if err != nil { + return err + } + + b, err := io.ReadAll(responseBody) + if err != nil { + return err + } + + var m interface{} + if bytes.HasPrefix(b, []byte("{")) { + if *v == nil { + *v = new(map[string]interface{}) + } + m = new(map[string]interface{}) + } else if bytes.HasPrefix(b, []byte("[")) { + if *v == nil { + *v = new([]interface{}) + } + m = new([]interface{}) + } + + err = json.Unmarshal(b, &m) + if err != nil { + return err + } + + return mergo.Merge(*v, m, mergo.WithAppendSlice, mergo.WithOverride) +} diff --git a/pkg/cmd/api/pagination_test.go b/pkg/cmd/api/pagination_test.go index 3bb1a8ec5c3..b9bfbdcbac2 100644 --- a/pkg/cmd/api/pagination_test.go +++ b/pkg/cmd/api/pagination_test.go @@ -2,9 +2,11 @@ package api import ( "bytes" - "io" + "encoding/json" "net/http" "testing" + + "github.com/stretchr/testify/assert" ) func Test_findNextPage(t *testing.T) { @@ -57,35 +59,35 @@ func Test_findNextPage(t *testing.T) { func Test_findEndCursor(t *testing.T) { tests := []struct { name string - json io.Reader + json string want string }{ { name: "blank", - json: bytes.NewBufferString(`{}`), + json: `{}`, want: "", }, { name: "unrelated fields", - json: bytes.NewBufferString(`{ + json: `{ "hasNextPage": true, "endCursor": "THE_END" - }`), + }`, want: "", }, { name: "has next page", - json: bytes.NewBufferString(`{ + json: `{ "pageInfo": { "hasNextPage": true, "endCursor": "THE_END" } - }`), + }`, want: "THE_END", }, { name: "more pageInfo blocks", - json: bytes.NewBufferString(`{ + json: `{ "pageInfo": { "hasNextPage": true, "endCursor": "THE_END" @@ -94,23 +96,24 @@ func Test_findEndCursor(t *testing.T) { "hasNextPage": true, "endCursor": "NOT_THIS" } - }`), + }`, want: "THE_END", }, { name: "no next page", - json: bytes.NewBufferString(`{ + json: `{ "pageInfo": { "hasNextPage": false, "endCursor": "THE_END" } - }`), + }`, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := findEndCursor(tt.json); got != tt.want { + json := bytes.NewReader([]byte(tt.json)) + if got := findEndCursor(json); got != tt.want { t.Errorf("findEndCursor() = %v, want %v", got, tt.want) } }) @@ -167,3 +170,155 @@ func Test_addPerPage(t *testing.T) { }) } } + +func Test_mergeJSON_object(t *testing.T) { + page1 := `{ + "data": { + "repository": { + "labels": { + "nodes": [ + { + "name": "bug", + "description": "Something isn't working" + }, + { + "name": "tracking issue", + "description": "" + } + ], + "pageInfo": { + "hasNextPage": true, + "endCursor": "Y3Vyc29yOnYyOpK5MjAxOS0xMC0xMFQxMTozODowMy0wNjowMM5f3HZq" + } + } + } + } + }` + + page2 := `{ + "data": { + "repository": { + "labels": { + "nodes": [ + { + "name": "blocked", + "description": "" + }, + { + "name": "needs-design", + "description": "An engineering task needs design to proceed" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK5MjAxOS0xMS0xOFQxMDowMzoxNi0wNzowMM5kXbLp" + } + } + } + } + }` + + var data interface{} + for _, page := range []string{page1, page2} { + err := mergeJSON(&data, bytes.NewReader([]byte(page))) + if !assert.NoError(t, err) { + return + } + } + + actual, err := json.Marshal(data) + if !assert.NoError(t, err) { + return + } + + expected := `{ + "data": { + "repository": { + "labels": { + "nodes": [ + { + "name": "bug", + "description": "Something isn't working" + }, + { + "name": "tracking issue", + "description": "" + }, + { + "name": "blocked", + "description": "" + }, + { + "name": "needs-design", + "description": "An engineering task needs design to proceed" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "Y3Vyc29yOnYyOpK5MjAxOS0xMS0xOFQxMDowMzoxNi0wNzowMM5kXbLp" + } + } + } + } + }` + + assert.JSONEq(t, expected, string(actual)) +} + +func Test_mergeJSON_array(t *testing.T) { + page1 := `[ + { + "name": "bug", + "description": "Something isn't working" + }, + { + "name": "tracking issue", + "description": "" + } + ]` + + page2 := `[ + { + "name": "blocked", + "description": "" + }, + { + "name": "needs-design", + "description": "An engineering task needs design to proceed" + } + ]` + + var data interface{} + for _, page := range []string{page1, page2} { + err := mergeJSON(&data, bytes.NewReader([]byte(page))) + if !assert.NoError(t, err) { + return + } + } + + actual, err := json.Marshal(data) + if !assert.NoError(t, err) { + return + } + + expected := `[ + { + "name": "bug", + "description": "Something isn't working" + }, + { + "name": "tracking issue", + "description": "" + }, + { + "name": "blocked", + "description": "" + }, + { + "name": "needs-design", + "description": "An engineering task needs design to proceed" + } + ]` + + assert.JSONEq(t, expected, string(actual)) +}