From 8b461047dfcbc854a6ae0b85fe4e96d9e0330f8b Mon Sep 17 00:00:00 2001 From: peterdeme Date: Thu, 2 Feb 2023 18:39:10 +0100 Subject: [PATCH 1/2] Add extensions to errors Signed-off-by: peterdeme --- .github/workflows/ci.yaml | 12 ++++++++ go.mod | 8 ++++++ go.sum | 19 +++++++++++++ graphql.go | 31 ++++++++++++++++++-- graphql_test.go | 60 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 go.mod create mode 100644 go.sum diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..af104a5 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,12 @@ +name: CI + +on: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@main + - uses: actions/setup-go@main + - run: go test -v ./... \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..47ab78b --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/shurcooL/graphql + +go 1.19 + +require ( + github.com/graph-gophers/graphql-go v1.5.0 + golang.org/x/net v0.5.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c314753 --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/graph-gophers/graphql-go v1.5.0 h1:fDqblo50TEpD0LY7RXk/LFVYEVqo3+tXMNMPSVXA1yc= +github.com/graph-gophers/graphql-go v1.5.0/go.mod h1:YtmJZDLbF1YYNrlNAuiO5zAStUWc3XZT07iGsVqe1Os= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI= +go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs= +golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/graphql.go b/graphql.go index 8520956..cad0f4d 100644 --- a/graphql.go +++ b/graphql.go @@ -74,11 +74,13 @@ func (c *Client) do(ctx context.Context, op operationType, v interface{}, variab body, _ := ioutil.ReadAll(resp.Body) return fmt.Errorf("non-200 OK status code: %v body: %q", resp.Status, body) } + var out struct { - Data *json.RawMessage - Errors errors - //Extensions interface{} // Unused. + Data *json.RawMessage + Errors errors + Extensions interface{} } + err = json.NewDecoder(resp.Body).Decode(&out) if err != nil { // TODO: Consider including response body in returned error, if deemed helpful. @@ -91,9 +93,15 @@ func (c *Client) do(ctx context.Context, op operationType, v interface{}, variab return err } } + if len(out.Errors) > 0 { + if out.Extensions != nil { + return newErrorsWithExtensions(out.Errors, out.Extensions) + } + return out.Errors } + return nil } @@ -121,3 +129,20 @@ const ( mutationOperation //subscriptionOperation // Unused. ) + +type ErrorsWithExtensions struct { + errors errors + extensions interface{} +} + +func newErrorsWithExtensions(err errors, extensions interface{}) ErrorsWithExtensions { + return ErrorsWithExtensions{errors: err, extensions: extensions} +} + +func (e ErrorsWithExtensions) Error() string { + return e.errors[0].Message +} + +func (e ErrorsWithExtensions) Extensions() interface{} { + return e.extensions +} diff --git a/graphql_test.go b/graphql_test.go index e09dcc9..fbdfa60 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -2,6 +2,7 @@ package graphql_test import ( "context" + "errors" "io" "io/ioutil" "net/http" @@ -101,6 +102,65 @@ func TestClient_Query_noDataWithErrorResponse(t *testing.T) { } } +func TestClient_Query_noDataWithErrorAndExtensionsResponse(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + mustWrite(w, `{ + "errors": [ + { + "message": "Field 'user' is missing required arguments: login", + "locations": [ + { + "line": 7, + "column": 3 + } + ] + } + ], + "extensions": { + "code": "MISSING_ARGUMENTS" + } + }`) + }) + client := graphql.NewClient("/graphql", &http.Client{Transport: localRoundTripper{handler: mux}}) + + var q struct { + User struct { + Name graphql.String + } + } + err := client.Query(context.Background(), &q, nil) + + if err == nil { + t.Fatal("got error: nil, want: non-nil") + } + + if got, want := err.Error(), "Field 'user' is missing required arguments: login"; got != want { + t.Errorf("got error: %v, want: %v", got, want) + } + + var graphErr graphql.ErrorsWithExtensions + if !errors.As(err, &graphErr) { + t.Fatalf("got error: %T, want: %T", err, graphErr) + } + + extensions := graphErr.Extensions() + asMap, ok := extensions.(map[string]interface{}) + + if !ok { + t.Fatalf("got error: %T, want: %T", graphErr.Extensions(), asMap) + } + + if got, want := asMap["code"], "MISSING_ARGUMENTS"; got != want { + t.Errorf("got error: %v, want: %v", got, want) + } + + if q.User.Name != "" { + t.Errorf("got non-empty q.User.Name: %v", q.User.Name) + } +} + func TestClient_Query_errorStatusCode(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { From d3a0cfc22ebd0dd5310cc28db02a8aafcf1b8158 Mon Sep 17 00:00:00 2001 From: Marcin Wyszynski Date: Wed, 5 May 2021 07:36:16 +0200 Subject: [PATCH 2/2] Request options, better errors --- errors.go | 46 +++++++++++++++++++++++++++++++++ graphql.go | 65 ++++++++++++++++++++++++++++++++--------------- request_option.go | 15 +++++++++++ 3 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 errors.go create mode 100644 request_option.go diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..7314217 --- /dev/null +++ b/errors.go @@ -0,0 +1,46 @@ +package graphql + +import "fmt" + +// RequestError represents an error building a request. +type RequestError struct{ Err error } + +func (e *RequestError) Error() string { return fmt.Sprintf("request error: %v", e.Err) } +func (e *RequestError) Unwrap() error { return e.Err } + +// OptionError represents an error modifiying a request. +type OptionError struct{ Err error } + +func (e *OptionError) Error() string { return fmt.Sprintf("request option error: %v", e.Err) } +func (e *OptionError) Unwrap() error { return e.Err } + +// ResponseError represents a response error, either with getting a response +// from the server (eg. network error), or reading its body. +type ResponseError struct{ Err error } + +func (e *ResponseError) Error() string { return fmt.Sprintf("request option error: %v", e.Err) } +func (e *ResponseError) Unwrap() error { return e.Err } + +// ServerError indicates that the server returned a response but it was not what +// we consider a successful one. +type ServerError struct { + Body []byte + Status string +} + +func (e *ServerError) Error() string { + return fmt.Sprintf("non-200 OK status code: %v body: %q", e.Status, e.Body) +} + +// BodyError indicates that the server responded with the right status code but +// the body was unexpected and it did not parse as a valid GraphQL response. +type BodyError struct { + Body []byte + Err error +} + +func (e *BodyError) Error() string { + return fmt.Sprintf("could not parse the body: %v, body: %q", e.Err, e.Body) +} + +func (e *BodyError) Unwrap() error { return e.Err } diff --git a/graphql.go b/graphql.go index cad0f4d..f426ade 100644 --- a/graphql.go +++ b/graphql.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "fmt" "io/ioutil" "net/http" @@ -14,38 +13,40 @@ import ( // Client is a GraphQL client. type Client struct { - url string // GraphQL server URL. - httpClient *http.Client + url string // GraphQL server URL. + httpClient *http.Client + requestOptions []RequestOption } // NewClient creates a GraphQL client targeting the specified GraphQL server URL. // If httpClient is nil, then http.DefaultClient is used. -func NewClient(url string, httpClient *http.Client) *Client { +func NewClient(url string, httpClient *http.Client, opts ...RequestOption) *Client { if httpClient == nil { httpClient = http.DefaultClient } return &Client{ - url: url, - httpClient: httpClient, + url: url, + httpClient: httpClient, + requestOptions: opts, } } // Query executes a single GraphQL query request, // with a query derived from q, populating the response into it. // q should be a pointer to struct that corresponds to the GraphQL schema. -func (c *Client) Query(ctx context.Context, q interface{}, variables map[string]interface{}) error { - return c.do(ctx, queryOperation, q, variables) +func (c *Client) Query(ctx context.Context, q interface{}, variables map[string]interface{}, opts ...RequestOption) error { + return c.do(ctx, queryOperation, q, variables, opts) } // Mutate executes a single GraphQL mutation request, // with a mutation derived from m, populating the response into it. // m should be a pointer to struct that corresponds to the GraphQL schema. -func (c *Client) Mutate(ctx context.Context, m interface{}, variables map[string]interface{}) error { - return c.do(ctx, mutationOperation, m, variables) +func (c *Client) Mutate(ctx context.Context, m interface{}, variables map[string]interface{}, opts ...RequestOption) error { + return c.do(ctx, mutationOperation, m, variables, opts) } // do executes a single GraphQL operation. -func (c *Client) do(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}) error { +func (c *Client) do(ctx context.Context, op operationType, v interface{}, variables map[string]interface{}, opts []RequestOption) error { var query string switch op { case queryOperation: @@ -53,6 +54,7 @@ func (c *Client) do(ctx context.Context, op operationType, v interface{}, variab case mutationOperation: query = constructMutation(v, variables) } + in := struct { Query string `json:"query"` Variables map[string]interface{} `json:"variables,omitempty"` @@ -65,14 +67,39 @@ func (c *Client) do(ctx context.Context, op operationType, v interface{}, variab if err != nil { return err } - resp, err := ctxhttp.Post(ctx, c.httpClient, c.url, "application/json", &buf) + + req, err := http.NewRequest(http.MethodPost, c.url, &buf) if err != nil { - return err + return &RequestError{Err: err} + } + req.Header.Set("Content-Type", "application/json") + + var allOpts []RequestOption + allOpts = append(allOpts, c.requestOptions...) + allOpts = append(allOpts, opts...) + + for _, opt := range allOpts { + if err := opt(req); err != nil { + return &OptionError{Err: err} + } + } + + resp, err := ctxhttp.Do(ctx, c.httpClient, req) + if err != nil { + return &ResponseError{Err: err} } defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return &ResponseError{Err: err} + } + if resp.StatusCode != http.StatusOK { - body, _ := ioutil.ReadAll(resp.Body) - return fmt.Errorf("non-200 OK status code: %v body: %q", resp.Status, body) + return &ServerError{ + Body: body, + Status: resp.Status, + } } var out struct { @@ -80,17 +107,15 @@ func (c *Client) do(ctx context.Context, op operationType, v interface{}, variab Errors errors Extensions interface{} } - err = json.NewDecoder(resp.Body).Decode(&out) if err != nil { - // TODO: Consider including response body in returned error, if deemed helpful. - return err + return &BodyError{Err: err, Body: body} } + if out.Data != nil { err := jsonutil.UnmarshalGraphQL(*out.Data, v) if err != nil { - // TODO: Consider including response body in returned error, if deemed helpful. - return err + return &BodyError{Err: err, Body: body} } } diff --git a/request_option.go b/request_option.go new file mode 100644 index 0000000..cbd57a7 --- /dev/null +++ b/request_option.go @@ -0,0 +1,15 @@ +package graphql + +import "net/http" + +// RequestOption allows you to modify the request before sending it to the +// server. +type RequestOption func(*http.Request) error + +// WithHeader sets a header on the request. +func WithHeader(key, value string) RequestOption { + return func(r *http.Request) error { + r.Header.Set(key, value) + return nil + } +}