From 77fa3ac703a5399ffab236edc8fda2ad75b219c3 Mon Sep 17 00:00:00 2001 From: Antti Kupila Date: Tue, 1 Oct 2019 11:19:16 +0200 Subject: [PATCH] expose details for returned errors Exposes the error as graphql.Error and returns error details (path, location, extensions) in case they are present in the error response. The format matches the June 2018 spec for errors: https://graphql.github.io/graphql-spec/June2018/#sec-Errors --- graphql.go | 60 +++++++++++++++++++++++++++++++++------ graphql_json_test.go | 49 +++++++++++++++++++++++++++++++- graphql_multipart_test.go | 47 +++++++++++++++++++++++++++++- 3 files changed, 145 insertions(+), 11 deletions(-) diff --git a/graphql.go b/graphql.go index 05c29b7..b4aed43 100644 --- a/graphql.go +++ b/graphql.go @@ -38,6 +38,7 @@ import ( "io" "mime/multipart" "net/http" + "strings" "github.com/pkg/errors" ) @@ -79,8 +80,17 @@ func (c *Client) logf(format string, args ...interface{}) { // Run executes the query and unmarshals the response from the data field // into the response object. // Pass in a nil response object to skip response parsing. -// If the request fails or the server returns an error, the first error -// will be returned. +// If the request fails or the server returns an error, the returned error will +// be of type Errors. Type assert to get the underlying errors: +// err := client.Run(..) +// if err != nil { +// if gqlErrors, ok := err.(graphql.Errors); ok { +// for _, e := range gqlErrors { +// // Server returned an error +// } +// } +// // Another error occurred +// } func (c *Client) Run(ctx context.Context, req *Request, resp interface{}) error { select { case <-ctx.Done(): @@ -144,8 +154,7 @@ func (c *Client) runWithJSON(ctx context.Context, req *Request, resp interface{} return errors.Wrap(err, "decoding response") } if len(gr.Errors) > 0 { - // return first error - return gr.Errors[0] + return gr.Errors } return nil } @@ -215,8 +224,7 @@ func (c *Client) runWithPostFields(ctx context.Context, req *Request, resp inter return errors.Wrap(err, "decoding response") } if len(gr.Errors) > 0 { - // return first error - return gr.Errors[0] + return gr.Errors } return nil } @@ -249,17 +257,51 @@ func ImmediatelyCloseReqBody() ClientOption { // modify the behaviour of the Client. type ClientOption func(*Client) -type graphErr struct { +// Errors contains all the errors that were returned by the GraphQL server. +type Errors []Error + +func (ee Errors) Error() string { + if len(ee) == 0 { + return "no errors" + } + errs := make([]string, len(ee)) + for i, e := range ee { + errs[i] = e.Message + } + return "graphql: " + strings.Join(errs, "; ") +} + +// An Error contains error information returned by the GraphQL server. +type Error struct { + // Message contains the error message. Message string + // Locations contains the locations in the GraphQL document that caused the + // error if the error can be associated to a particular point in the + // requested GraphQL document. + Locations []Location + // Path contains the key path of the response field which experienced the + // error. This allows clients to identify whether a nil result is + // intentional or caused by a runtime error. + Path []interface{} + // Extensions may contain additional fields set by the GraphQL service, + // such as an error code. + Extensions map[string]interface{} +} + +// A Location is a location in the GraphQL query that resulted in an error. +// The location may be returned as part of an error response. +type Location struct { + Line int + Column int } -func (e graphErr) Error() string { +func (e Error) Error() string { return "graphql: " + e.Message } type graphResponse struct { Data interface{} - Errors []graphErr + Errors Errors } // Request is a GraphQL request. diff --git a/graphql_json_test.go b/graphql_json_test.go index a973d2d..d7e8029 100644 --- a/graphql_json_test.go +++ b/graphql_json_test.go @@ -79,6 +79,8 @@ func TestDoJSONBadRequestErr(t *testing.T) { io.WriteString(w, `{ "errors": [{ "message": "miscellaneous message as to why the the request was bad" + }, { + "message": "another error" }] }`) })) @@ -92,7 +94,52 @@ func TestDoJSONBadRequestErr(t *testing.T) { var responseData map[string]interface{} err := client.Run(ctx, &Request{q: "query {}"}, &responseData) is.Equal(calls, 1) // calls - is.Equal(err.Error(), "graphql: miscellaneous message as to why the the request was bad") + is.Equal(err.Error(), "graphql: miscellaneous message as to why the the request was bad; another error") +} + +func TestDoJSONBadRequestErrDetails(t *testing.T) { + is := is.New(t) + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + is.Equal(r.Method, http.MethodPost) + b, err := ioutil.ReadAll(r.Body) + is.NoErr(err) + is.Equal(string(b), `{"query":"query {}","variables":null}`+"\n") + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{ + "errors": [{ + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [ { "line": 6, "column": 7 } ], + "path": [ "hero", "heroFriends", 1, "name" ], + "extensions": { + "code": "CAN_NOT_FETCH_BY_ID", + "timestamp": "Fri Feb 9 14:33:09 UTC 2018" + } + }] + }`) + })) + defer srv.Close() + + ctx := context.Background() + client := NewClient(srv.URL) + + ctx, cancel := context.WithTimeout(ctx, 1*time.Second) + defer cancel() + var responseData map[string]interface{} + err := client.Run(ctx, &Request{q: "query {}"}, &responseData) + is.Equal(calls, 1) // calls + errs, ok := err.(Errors) + is.True(ok) + is.Equal(len(errs), 1) + e := errs[0] + is.Equal(e.Message, "Name for character with ID 1002 could not be fetched.") + is.Equal(e.Locations, []Location{{Line: 6, Column: 7}}) + is.Equal(e.Path, []interface{}{"hero", "heroFriends", 1.0, "name"}) + is.Equal(e.Extensions, map[string]interface{}{ + "code": "CAN_NOT_FETCH_BY_ID", + "timestamp": "Fri Feb 9 14:33:09 UTC 2018", + }) } func TestQueryJSON(t *testing.T) { diff --git a/graphql_multipart_test.go b/graphql_multipart_test.go index b52da2c..ff00960 100644 --- a/graphql_multipart_test.go +++ b/graphql_multipart_test.go @@ -101,6 +101,8 @@ func TestDoErr(t *testing.T) { io.WriteString(w, `{ "errors": [{ "message": "Something went wrong" + }, { + "message": "Something else went wrong" }] }`) })) @@ -114,7 +116,7 @@ func TestDoErr(t *testing.T) { var responseData map[string]interface{} err := client.Run(ctx, &Request{q: "query {}"}, &responseData) is.True(err != nil) - is.Equal(err.Error(), "graphql: Something went wrong") + is.Equal(err.Error(), "graphql: Something went wrong; Something else went wrong") } func TestDoServerErr(t *testing.T) { @@ -167,6 +169,49 @@ func TestDoBadRequestErr(t *testing.T) { is.Equal(err.Error(), "graphql: miscellaneous message as to why the the request was bad") } +func TestDoBadRequestErrDetails(t *testing.T) { + is := is.New(t) + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + is.Equal(r.Method, http.MethodPost) + query := r.FormValue("query") + is.Equal(query, `query {}`) + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{ + "errors": [{ + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [ { "line": 6, "column": 7 } ], + "path": [ "hero", "heroFriends", 1, "name" ], + "extensions": { + "code": "CAN_NOT_FETCH_BY_ID", + "timestamp": "Fri Feb 9 14:33:09 UTC 2018" + } + }] + }`) + })) + defer srv.Close() + + ctx := context.Background() + client := NewClient(srv.URL, UseMultipartForm()) + + ctx, cancel := context.WithTimeout(ctx, 1*time.Second) + defer cancel() + var responseData map[string]interface{} + err := client.Run(ctx, &Request{q: "query {}"}, &responseData) + errs, ok := err.(Errors) + is.True(ok) + is.Equal(len(errs), 1) + e := errs[0] + is.Equal(e.Message, "Name for character with ID 1002 could not be fetched.") + is.Equal(e.Locations, []Location{{Line: 6, Column: 7}}) + is.Equal(e.Path, []interface{}{"hero", "heroFriends", 1.0, "name"}) + is.Equal(e.Extensions, map[string]interface{}{ + "code": "CAN_NOT_FETCH_BY_ID", + "timestamp": "Fri Feb 9 14:33:09 UTC 2018", + }) +} + func TestDoNoResponse(t *testing.T) { is := is.New(t) var calls int