Skip to content

Commit

Permalink
expose details for returned errors
Browse files Browse the repository at this point in the history
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
  • Loading branch information
akupila committed Oct 1, 2019
1 parent 3a92531 commit 77fa3ac
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 11 deletions.
60 changes: 51 additions & 9 deletions graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
"io"
"mime/multipart"
"net/http"
"strings"

"github.com/pkg/errors"
)
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down
49 changes: 48 additions & 1 deletion graphql_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}]
}`)
}))
Expand All @@ -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) {
Expand Down
47 changes: 46 additions & 1 deletion graphql_multipart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ func TestDoErr(t *testing.T) {
io.WriteString(w, `{
"errors": [{
"message": "Something went wrong"
}, {
"message": "Something else went wrong"
}]
}`)
}))
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 77fa3ac

Please sign in to comment.