Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 94 additions & 17 deletions pkg/api/graphql_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ func NewGraphQLClient(opts ClientOptions) (*GraphQLClient, error) {
endpoint = swapHost(endpoint, opts.APIHost)
}

graphqlHTTPClient := *httpClient
graphqlHTTPClient.Transport = graphQLHTTPErrorCaptureRoundTripper{rt: httpClient.Transport}

return &GraphQLClient{
client: graphql.NewClient(endpoint, httpClient),
client: graphql.NewClient(endpoint, &graphqlHTTPClient),
endpoint: endpoint,
httpClient: httpClient,
}, nil
Expand Down Expand Up @@ -121,7 +124,19 @@ func (c *GraphQLClient) Do(query string, variables map[string]interface{}, respo
// to the GitHub GraphQL schema.
// Provided input will be set as a variable named input.
func (c *GraphQLClient) MutateWithContext(ctx context.Context, name string, m interface{}, variables map[string]interface{}) error {
capture := &graphQLHTTPErrorCapture{}
ctx = context.WithValue(ctx, graphQLHTTPErrorCaptureKey{}, capture)
err := c.client.MutateNamed(ctx, name, m, variables)
return normalizeGraphQLError(err, capture)
}

func normalizeGraphQLError(err error, capture *graphQLHTTPErrorCapture) error {
if err != nil {
if httpErr := capture.httpError(); httpErr != nil {
return httpErr
}
}

var graphQLErrs graphql.Errors
if err != nil && errors.As(err, &graphQLErrs) {
items := make([]GraphQLErrorItem, len(graphQLErrs))
Expand All @@ -134,8 +149,14 @@ func (c *GraphQLClient) MutateWithContext(ctx context.Context, name string, m in
Type: e.Type,
}
}
err = &GraphQLError{items}
return &GraphQLError{items}
}

var httpErr *HTTPError
if err != nil && errors.As(err, &httpErr) {
return httpErr
}

return err
}

Expand All @@ -150,22 +171,10 @@ func (c *GraphQLClient) Mutate(name string, m interface{}, variables map[string]
// The query argument should be a pointer to struct that corresponds
// to the GitHub GraphQL schema.
func (c *GraphQLClient) QueryWithContext(ctx context.Context, name string, q interface{}, variables map[string]interface{}) error {
capture := &graphQLHTTPErrorCapture{}
ctx = context.WithValue(ctx, graphQLHTTPErrorCaptureKey{}, capture)
err := c.client.QueryNamed(ctx, name, q, variables)
var graphQLErrs graphql.Errors
if err != nil && errors.As(err, &graphQLErrs) {
items := make([]GraphQLErrorItem, len(graphQLErrs))
for i, e := range graphQLErrs {
items[i] = GraphQLErrorItem{
Message: e.Message,
Locations: e.Locations,
Path: e.Path,
Extensions: e.Extensions,
Type: e.Type,
}
}
err = &GraphQLError{items}
}
return err
return normalizeGraphQLError(err, capture)
}

// Query wraps QueryWithContext using context.Background.
Expand All @@ -178,6 +187,74 @@ type graphQLResponse struct {
Errors []GraphQLErrorItem
}

type graphQLHTTPErrorCaptureKey struct{}

type graphQLHTTPErrorCapture struct {
response *http.Response
body bytes.Buffer
}

func (capture *graphQLHTTPErrorCapture) reset() {
capture.response = nil
capture.body.Reset()
}

func (capture *graphQLHTTPErrorCapture) errorResponse(resp *http.Response, req *http.Request) {
copy := *resp
copy.Header = resp.Header.Clone()
copy.Body = nil
copy.Request = req
capture.response = &copy
if resp.Body != nil {
resp.Body = &graphQLHTTPErrorCaptureBody{ReadCloser: resp.Body, capture: capture}
}
}

func (capture *graphQLHTTPErrorCapture) httpError() *HTTPError {
if capture.response == nil {
return nil
}

resp := *capture.response
resp.Body = io.NopCloser(bytes.NewReader(capture.body.Bytes()))
httpErr, _ := HandleHTTPError(&resp).(*HTTPError)
return httpErr
}

type graphQLHTTPErrorCaptureBody struct {
io.ReadCloser
capture *graphQLHTTPErrorCapture
}

func (body *graphQLHTTPErrorCaptureBody) Read(p []byte) (int, error) {
n, err := body.ReadCloser.Read(p)
if n > 0 {
_, _ = body.capture.body.Write(p[:n])
}
return n, err
}

// graphQLHTTPErrorCaptureRoundTripper keeps HTTP errors as responses so the
// GraphQL client can close the body, while retaining details for normalization.
type graphQLHTTPErrorCaptureRoundTripper struct {
rt http.RoundTripper
}

func (rt graphQLHTTPErrorCaptureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
capture, _ := req.Context().Value(graphQLHTTPErrorCaptureKey{}).(*graphQLHTTPErrorCapture)
if capture != nil {
capture.reset()
}

resp, err := rt.rt.RoundTrip(req)
if err != nil || resp == nil || resp.StatusCode >= 200 && resp.StatusCode < 300 || capture == nil {
return resp, err
}

capture.errorResponse(resp, req)
return resp, nil
}

func graphQLEndpoint(host string) string {
if isGarage(host) {
return fmt.Sprintf("https://%s/api/graphql", host)
Expand Down
89 changes: 89 additions & 0 deletions pkg/api/graphql_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package api
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"

Expand All @@ -12,6 +14,12 @@ import (
"gopkg.in/h2non/gock.v1"
)

type roundTripperFunc func(*http.Request) (*http.Response, error)

func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

func TestGraphQLClient(t *testing.T) {
testutils.StubConfig(t, testConfig())
t.Cleanup(gock.Off)
Expand Down Expand Up @@ -103,6 +111,87 @@ func TestGraphQLClientMutateError(t *testing.T) {
assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending()))
}

func TestGraphQLClientQueryHTTPError(t *testing.T) {
testutils.StubConfig(t, testConfig())
t.Cleanup(gock.Off)

gock.New("https://api.github.com").
Post("/graphql").
MatchHeader("Authorization", "token abc123").
Reply(http.StatusUnauthorized).
JSON(`{"message":"Bad credentials"}`)

client, err := DefaultGraphQLClient()
assert.NoError(t, err)

var res struct{ Viewer struct{ Login string } }
err = client.Query("QUERY", &res, nil)
var httpErr *HTTPError
assert.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusUnauthorized, httpErr.StatusCode)
assert.Equal(t, "Bad credentials", httpErr.Message)
assert.EqualError(t, err, "HTTP 401: Bad credentials (https://api.github.com/graphql)")
assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending()))
}

func TestGraphQLClientMutateHTTPError(t *testing.T) {
testutils.StubConfig(t, testConfig())
t.Cleanup(gock.Off)

gock.New("https://api.github.com").
Post("/graphql").
MatchHeader("Authorization", "token abc123").
Reply(http.StatusForbidden).
JSON(`{"message":"Forbidden"}`)

client, err := DefaultGraphQLClient()
assert.NoError(t, err)

var mutation struct {
UpdateRepository struct{ Repository struct{ Name string } }
}
err = client.Mutate("MUTATE", &mutation, nil)
var httpErr *HTTPError
assert.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusForbidden, httpErr.StatusCode)
assert.Equal(t, "Forbidden", httpErr.Message)
assert.EqualError(t, err, "HTTP 403: Forbidden (https://api.github.com/graphql)")
assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending()))
}

func TestGraphQLErrorRoundTripperReturnsResponseWithoutTransportError(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "https://api.github.com/graphql", nil)
assert.NoError(t, err)
headers := make(http.Header)
headers.Set("Content-Type", "application/json")
headers.Set("X-GitHub-Request-Id", "test-request-id")
resp := &http.Response{
Status: "401 Unauthorized",
StatusCode: http.StatusUnauthorized,
Header: headers,
Body: io.NopCloser(strings.NewReader(`{"message":"Bad credentials"}`)),
Request: req,
}
capture := &graphQLHTTPErrorCapture{}
req = req.WithContext(context.WithValue(req.Context(), graphQLHTTPErrorCaptureKey{}, capture))
roundTripper := graphQLHTTPErrorCaptureRoundTripper{rt: roundTripperFunc(func(*http.Request) (*http.Response, error) {
return resp, nil
})}

got, err := roundTripper.RoundTrip(req)
assert.NoError(t, err)
assert.Same(t, resp, got)
body, err := io.ReadAll(got.Body)
assert.NoError(t, err)
assert.Equal(t, `{"message":"Bad credentials"}`, string(body))
assert.NoError(t, got.Body.Close())

httpErr := capture.httpError()
assert.Equal(t, http.StatusUnauthorized, httpErr.StatusCode)
assert.Equal(t, "Bad credentials", httpErr.Message)
assert.Equal(t, "test-request-id", httpErr.Headers.Get("X-GitHub-Request-Id"))
}

func TestGraphQLClientDo(t *testing.T) {
testutils.StubConfig(t, "")

Expand Down