Skip to content

Commit

Permalink
Merge pull request #186 from vektah/error-redux
Browse files Browse the repository at this point in the history
Error redux
  • Loading branch information
vektah committed Jul 14, 2018
2 parents 0a9709d + b884239 commit febd035
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 98 deletions.
100 changes: 100 additions & 0 deletions docs/content/reference/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
linkTitle: Handling Errors
title: Sending custom error data in the graphql response
description: Customising graphql error types to send custom error data back to the client using gqlgen.
menu: main
---

## Returning errors

All resolvers simply return an error to send it to the user. Its assumed that any error message returned
here is safe for users, if certain messages arent safe, customize the error presenter.

### Multiple errors

To return multiple errors you can call the `graphql.Error` functions like so:

```go
func (r Query) DoThings(ctx context.Context) (bool, error) {
// Print a formatted string
graphql.AddErrorf(ctx, "Error %d", 1)

// Pass an existing error out
graphql.AddError(ctx, errors.New("zzzzzt"))

// Or fully customize the error
graphql.AddError(ctx, &graphql.Error{
Message: "A descriptive error message",
Extensions: map[string]interface{}{
"code": "10-4",
},
})

// And you can still return an error if you need
return nil, errors.New("BOOM! Headshot")
}
```

They will be returned in the same order in the response, eg:
```json
{
"data": {
"todo": null
},
"errors": [
{ "message": "Error 1", "path": [ "todo" ] },
{ "message": "zzzzzt", "path": [ "todo" ] },
{ "message": "A descriptive error message", "path": [ "todo" ], "extensions": { "code": "10-4" } },
{ "message": "BOOM! Headshot", "path": [ "todo" ] }
]
}
```

## Hooks

### The error presenter

All `errors` returned by resolvers, or from validation pass through a hook before being displayed to the user.
This hook gives you the ability to customize errors however makes sense in your app.

The default error presenter will capture the resolver path and use the Error() message in the response. It will
also call an Extensions() method if one is present to return graphql extensions.

You change this when creating the handler:
```go
server := handler.GraphQL(MakeExecutableSchema(resolvers),
handler.ErrorPresenter(
func(ctx context.Context, e error) *graphql.Error {
// any special logic you want to do here. This only
// requirement is that it can be json encoded
if myError, ok := e.(MyError) ; ok {
return &graphql.Error{Message: "Eeek!"}
}

return graphql.DefaultErrorPresenter(ctx, e)
}
),
)
```

This function will be called with the the same resolver context that threw generated it, so you can extract the
current resolver path and whatever other state you might want to notify the client about.


### The panic handler

There is also a panic handler, called whenever a panic happens to gracefully return a message to the user before
stopping parsing. This is a good spot to notify your bug tracker and send a custom message to the user. Any errors
returned from here will also go through the error presenter.

You change this when creating the handler:
```go
server := handler.GraphQL(MakeExecutableSchema(resolvers),
handler.RecoverFunc(func(ctx context.Context, err interface{}) error {
// notify bug tracker...

return fmt.Errorf("Internal server error!")
}
}
```

46 changes: 38 additions & 8 deletions graphql/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package graphql

import (
"context"
"fmt"
"sync"

"github.com/vektah/gqlgen/neelance/query"
)
Expand All @@ -11,14 +13,18 @@ type ResolverMiddleware func(ctx context.Context, next Resolver) (res interface{
type RequestMiddleware func(ctx context.Context, next func(ctx context.Context) []byte) []byte

type RequestContext struct {
ErrorBuilder

RawQuery string
Variables map[string]interface{}
Doc *query.Document
RawQuery string
Variables map[string]interface{}
Doc *query.Document
// ErrorPresenter will be used to generate the error
// message from errors given to Error().
ErrorPresenter ErrorPresenterFunc
Recover RecoverFunc
ResolverMiddleware ResolverMiddleware
RequestMiddleware RequestMiddleware

errorsMu sync.Mutex
Errors []*Error
}

func DefaultResolverMiddleware(ctx context.Context, next Resolver) (res interface{}, err error) {
Expand All @@ -37,9 +43,7 @@ func NewRequestContext(doc *query.Document, query string, variables map[string]i
ResolverMiddleware: DefaultResolverMiddleware,
RequestMiddleware: DefaultRequestMiddleware,
Recover: DefaultRecover,
ErrorBuilder: ErrorBuilder{
ErrorPresenter: DefaultErrorPresenter,
},
ErrorPresenter: DefaultErrorPresenter,
}
}

Expand Down Expand Up @@ -113,3 +117,29 @@ func CollectFieldsCtx(ctx context.Context, satisfies []string) []CollectedField
resctx := GetResolverContext(ctx)
return CollectFields(reqctx.Doc, resctx.Field.Selections, satisfies, reqctx.Variables)
}

// Errorf sends an error string to the client, passing it through the formatter.
func (c *RequestContext) Errorf(ctx context.Context, format string, args ...interface{}) {
c.errorsMu.Lock()
defer c.errorsMu.Unlock()

c.Errors = append(c.Errors, c.ErrorPresenter(ctx, fmt.Errorf(format, args...)))
}

// Error sends an error to the client, passing it through the formatter.
func (c *RequestContext) Error(ctx context.Context, err error) {
c.errorsMu.Lock()
defer c.errorsMu.Unlock()

c.Errors = append(c.Errors, c.ErrorPresenter(ctx, err))
}

// AddError is a convenience method for adding an error to the current response
func AddError(ctx context.Context, err error) {
GetRequestContext(ctx).Error(ctx, err)
}

// AddErrorf is a convenience method for adding an error to the current response
func AddErrorf(ctx context.Context, format string, args ...interface{}) {
GetRequestContext(ctx).Errorf(ctx, format, args...)
}
61 changes: 29 additions & 32 deletions graphql/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,45 @@ package graphql

import (
"context"
"fmt"
"sync"
)

type ErrorPresenterFunc func(context.Context, error) error

func DefaultErrorPresenter(ctx context.Context, err error) error {
return &ResolverError{
Message: err.Error(),
Path: GetResolverContext(ctx).Path,
}
}

// ResolverError is the default error type returned by ErrorPresenter. You can replace it with your own by returning
// something different from the ErrorPresenter
type ResolverError struct {
Message string `json:"message"`
Path []interface{} `json:"path,omitempty"`
// Error is the standard graphql error type described in https://facebook.github.io/graphql/draft/#sec-Errors
type Error struct {
Message string `json:"message"`
Path []interface{} `json:"path,omitempty"`
Locations []ErrorLocation `json:"locations,omitempty"`
Extensions map[string]interface{} `json:"extensions,omitempty"`
}

func (r *ResolverError) Error() string {
return r.Message
func (e *Error) Error() string {
return e.Message
}

type ErrorBuilder struct {
Errors []error
// ErrorPresenter will be used to generate the error
// message from errors given to Error().
ErrorPresenter ErrorPresenterFunc
mu sync.Mutex
type ErrorLocation struct {
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
}

func (c *ErrorBuilder) Errorf(ctx context.Context, format string, args ...interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
type ErrorPresenterFunc func(context.Context, error) *Error

c.Errors = append(c.Errors, c.ErrorPresenter(ctx, fmt.Errorf(format, args...)))
type ExtendedError interface {
Extensions() map[string]interface{}
}

func (c *ErrorBuilder) Error(ctx context.Context, err error) {
c.mu.Lock()
defer c.mu.Unlock()
func DefaultErrorPresenter(ctx context.Context, err error) *Error {
if gqlerr, ok := err.(*Error); ok {
gqlerr.Path = GetResolverContext(ctx).Path
return gqlerr
}

var extensions map[string]interface{}
if ee, ok := err.(ExtendedError); ok {
extensions = ee.Extensions()
}

c.Errors = append(c.Errors, c.ErrorPresenter(ctx, err))
return &Error{
Message: err.Error(),
Path: GetResolverContext(ctx).Path,
Extensions: extensions,
}
}
50 changes: 0 additions & 50 deletions graphql/errors_test.go

This file was deleted.

4 changes: 2 additions & 2 deletions graphql/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import (

type Response struct {
Data json.RawMessage `json:"data"`
Errors []error `json:"errors,omitempty"`
Errors []*Error `json:"errors,omitempty"`
}

func ErrorResponse(ctx context.Context, messagef string, args ...interface{}) *Response {
return &Response{
Errors: []error{&ResolverError{Message: fmt.Sprintf(messagef, args...)}},
Errors: []*Error{{Message: fmt.Sprintf(messagef, args...)}},
}
}
17 changes: 15 additions & 2 deletions handler/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,22 @@ func GraphQL(exec graphql.ExecutableSchema, options ...Option) http.HandlerFunc

func sendError(w http.ResponseWriter, code int, errors ...*errors.QueryError) {
w.WriteHeader(code)
var errs []error
var errs []*graphql.Error
for _, err := range errors {
errs = append(errs, err)
var locations []graphql.ErrorLocation
for _, l := range err.Locations {
fmt.Println(graphql.ErrorLocation(l))
locations = append(locations, graphql.ErrorLocation{
Line: l.Line,
Column: l.Column,
})
}

errs = append(errs, &graphql.Error{
Message: err.Message,
Path: err.Path,
Locations: locations,
})
}
b, err := json.Marshal(&graphql.Response{Errors: errs})
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion handler/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ func (c *wsConnection) sendError(id string, errors ...*errors.QueryError) {
}

func (c *wsConnection) sendConnectionError(format string, args ...interface{}) {
b, err := json.Marshal(&graphql.ResolverError{Message: fmt.Sprintf(format, args...)})
b, err := json.Marshal(&graphql.Error{Message: fmt.Sprintf(format, args...)})
if err != nil {
panic(err)
}
Expand Down
19 changes: 16 additions & 3 deletions test/resolvers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ import (
func TestCustomErrorPresenter(t *testing.T) {
resolvers := &testResolvers{}
srv := httptest.NewServer(handler.GraphQL(MakeExecutableSchema(resolvers),
handler.ErrorPresenter(func(i context.Context, e error) error {
handler.ErrorPresenter(func(i context.Context, e error) *graphql.Error {
if _, ok := errors.Cause(e).(*specialErr); ok {
return &graphql.ResolverError{Message: "override special error message"}
return &graphql.Error{Message: "override special error message"}
}
return &graphql.ResolverError{Message: e.Error()}
return &graphql.Error{Message: e.Error()}
}),
))
c := client.New(srv.URL)
Expand All @@ -46,6 +46,19 @@ func TestCustomErrorPresenter(t *testing.T) {

assert.EqualError(t, err, `[{"message":"a normal error"},{"message":"a normal error"},{"message":"a normal error"},{"message":"a normal error"}]`)
})
t.Run("multiple errors", func(t *testing.T) {
resolvers.queryDate = func(ctx context.Context, filter models.DateFilter) (bool, error) {
graphql.AddErrorf(ctx, "Error 1")
graphql.AddErrorf(ctx, "Error 2")
graphql.AddError(ctx, &specialErr{})
return false, nil
}

var resp struct{ Date bool }
err := c.Post(`{ date(filter:{value: "asdf"}) }`, &resp)

assert.EqualError(t, err, `[{"message":"Error 1"},{"message":"Error 2"},{"message":"override special error message"}]`)
})
}

func TestErrorPath(t *testing.T) {
Expand Down

0 comments on commit febd035

Please sign in to comment.