From 177b9a1a8f94288d69e26efe38e819eeb212409d Mon Sep 17 00:00:00 2001 From: Matias Anaya Date: Wed, 25 Apr 2018 18:33:53 +1000 Subject: [PATCH 01/69] Support subscriptions --- gqltesting/subscriptions.go | 110 ++++++++++++++ internal/exec/resolvable/resolvable.go | 44 ++++-- internal/exec/selected/selected.go | 2 + internal/exec/subscribe.go | 147 +++++++++++++++++++ subscription_test.go | 196 +++++++++++++++++++++++++ subscriptions.go | 91 ++++++++++++ 6 files changed, 577 insertions(+), 13 deletions(-) create mode 100644 gqltesting/subscriptions.go create mode 100644 internal/exec/subscribe.go create mode 100644 subscription_test.go create mode 100644 subscriptions.go diff --git a/gqltesting/subscriptions.go b/gqltesting/subscriptions.go new file mode 100644 index 00000000..7a8ea43e --- /dev/null +++ b/gqltesting/subscriptions.go @@ -0,0 +1,110 @@ +package gqltesting + +import ( + "bytes" + "context" + "encoding/json" + "strconv" + "testing" + + graphql "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/errors" +) + +// TestResponse models the expected response +type TestResponse struct { + Data json.RawMessage + Errors []*errors.QueryError +} + +// TestSubscription is a GraphQL test case to be used with RunSubscribe. +type TestSubscription struct { + Name string + Schema *graphql.Schema + Query string + OperationName string + Variables map[string]interface{} + ExpectedResults []TestResponse + ExpectedErr error +} + +// RunSubscribes runs the given GraphQL subscription test cases as subtests. +func RunSubscribes(t *testing.T, tests []*TestSubscription) { + for i, test := range tests { + if test.Name == "" { + test.Name = strconv.Itoa(i + 1) + } + + t.Run(test.Name, func(t *testing.T) { + RunSubscribe(t, test) + }) + } +} + +// RunSubscribe runs a single GraphQL subscription test case. +func RunSubscribe(t *testing.T, test *TestSubscription) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + c, err := test.Schema.Subscribe(ctx, test.Query, test.OperationName, test.Variables) + if err != nil { + if err.Error() != test.ExpectedErr.Error() { + t.Fatalf("unexpected error: got %+v, want %+v", err, test.ExpectedErr) + } + + return + } + + var results []*graphql.Response + for res := range c { + results = append(results, res) + } + + for i, expected := range test.ExpectedResults { + res := results[i] + + checkErrorStrings(t, expected.Errors, res.Errors) + + resData, err := res.Data.MarshalJSON() + if err != nil { + t.Fatal(err) + } + got := formatJSON(t, resData) + expectedData, err := expected.Data.MarshalJSON() + if err != nil { + t.Fatal(err) + } + want := formatJSON(t, expectedData) + + if !bytes.Equal(got, want) { + t.Logf("got: %s", got) + t.Logf("want: %s", want) + t.Fail() + } + } +} + +func checkErrorStrings(t *testing.T, expected, actual []*errors.QueryError) { + expectedCount, actualCount := len(expected), len(actual) + + if expectedCount != actualCount { + t.Fatalf("unexpected number of errors: want %d, got %d", expectedCount, actualCount) + } + + if expectedCount > 0 { + for i, want := range expected { + got := actual[i] + + if got.Error() != want.Error() { + t.Fatalf("unexpected error: got %+v, want %+v", got, want) + } + } + + // Return because we're done checking. + return + } + + for _, err := range actual { + t.Errorf("unexpected error: '%s'", err) + } +} diff --git a/internal/exec/resolvable/resolvable.go b/internal/exec/resolvable/resolvable.go index b7c1d93d..aa2fc1bd 100644 --- a/internal/exec/resolvable/resolvable.go +++ b/internal/exec/resolvable/resolvable.go @@ -13,9 +13,10 @@ import ( type Schema struct { schema.Schema - Query Resolvable - Mutation Resolvable - Resolver reflect.Value + Query Resolvable + Mutation Resolvable + Subscription Resolvable + Resolver reflect.Value } type Resolvable interface { @@ -57,7 +58,7 @@ func (*Scalar) isResolvable() {} func ApplyResolver(s *schema.Schema, resolver interface{}) (*Schema, error) { b := newBuilder(s) - var query, mutation Resolvable + var query, mutation, subscription Resolvable if t, ok := s.EntryPoints["query"]; ok { if err := b.assignExec(&query, t, reflect.TypeOf(resolver)); err != nil { @@ -71,15 +72,22 @@ func ApplyResolver(s *schema.Schema, resolver interface{}) (*Schema, error) { } } + if t, ok := s.EntryPoints["subscription"]; ok { + if err := b.assignExec(&subscription, t, reflect.TypeOf(resolver)); err != nil { + return nil, err + } + } + if err := b.finish(); err != nil { return nil, err } return &Schema{ - Schema: *s, - Resolver: reflect.ValueOf(resolver), - Query: query, - Mutation: mutation, + Schema: *s, + Resolver: reflect.ValueOf(resolver), + Query: query, + Mutation: mutation, + Subscription: subscription, }, nil } @@ -284,14 +292,19 @@ func (b *execBuilder) makeFieldExec(typeName string, f *schema.Field, m reflect. return nil, fmt.Errorf("too many parameters") } - if m.Type.NumOut() > 2 { + maxNumOfReturns := 2 + if m.Type.NumOut() < maxNumOfReturns-1 { + return nil, fmt.Errorf("too few return values") + } + + if m.Type.NumOut() > maxNumOfReturns { return nil, fmt.Errorf("too many return values") } - hasError := m.Type.NumOut() == 2 + hasError := m.Type.NumOut() == maxNumOfReturns if hasError { - if m.Type.Out(1) != errorType { - return nil, fmt.Errorf(`must have "error" as its second return value`) + if m.Type.Out(maxNumOfReturns-1) != errorType { + return nil, fmt.Errorf(`must have "error" as its last return value`) } } @@ -304,7 +317,12 @@ func (b *execBuilder) makeFieldExec(typeName string, f *schema.Field, m reflect. HasError: hasError, TraceLabel: fmt.Sprintf("GraphQL field: %s.%s", typeName, f.Name), } - if err := b.assignExec(&fe.ValueExec, f.Type, m.Type.Out(0)); err != nil { + + out := m.Type.Out(0) + if typeName == "Subscription" && out.Kind() == reflect.Chan { + out = m.Type.Out(0).Elem() + } + if err := b.assignExec(&fe.ValueExec, f.Type, out); err != nil { return nil, err } return fe, nil diff --git a/internal/exec/selected/selected.go b/internal/exec/selected/selected.go index aed079b6..2a957f55 100644 --- a/internal/exec/selected/selected.go +++ b/internal/exec/selected/selected.go @@ -35,6 +35,8 @@ func ApplyOperation(r *Request, s *resolvable.Schema, op *query.Operation) []Sel obj = s.Query.(*resolvable.Object) case query.Mutation: obj = s.Mutation.(*resolvable.Object) + case query.Subscription: + obj = s.Subscription.(*resolvable.Object) } return applySelectionSet(r, obj, op.Selections) } diff --git a/internal/exec/subscribe.go b/internal/exec/subscribe.go new file mode 100644 index 00000000..03826f89 --- /dev/null +++ b/internal/exec/subscribe.go @@ -0,0 +1,147 @@ +package exec + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "reflect" + "time" + + "github.com/graph-gophers/graphql-go/errors" + "github.com/graph-gophers/graphql-go/internal/exec/resolvable" + "github.com/graph-gophers/graphql-go/internal/exec/selected" + "github.com/graph-gophers/graphql-go/internal/query" +) + +type Response struct { + Data json.RawMessage + Errors []*errors.QueryError +} + +func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *query.Operation) <-chan *Response { + var result reflect.Value + var f *fieldToExec + var err *errors.QueryError + func() { + defer r.handlePanic(ctx) + + sels := selected.ApplyOperation(&r.Request, s, op) + var fields []*fieldToExec + collectFieldsToResolve(sels, s.Resolver, &fields, make(map[string]*fieldToExec)) + + // TODO: move this check into validation.Validate + if len(fields) != 1 { + err = errors.Errorf("%s", "can subscribe to at most one subscription at a time") + return + } + f = fields[0] + + var in []reflect.Value + if f.field.HasContext { + in = append(in, reflect.ValueOf(ctx)) + } + if f.field.ArgsPacker != nil { + in = append(in, f.field.PackedArgs) + } + callOut := f.resolver.Method(f.field.MethodIndex).Call(in) + result = callOut[0] + + if f.field.HasError && !callOut[1].IsNil() { + resolverErr := callOut[1].Interface().(error) + err = errors.Errorf("%s", resolverErr) + err.ResolverError = resolverErr + } + }() + + if err != nil { + return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{err}}) + } + + if ctxErr := ctx.Err(); ctxErr != nil { + return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{errors.Errorf("%s", ctxErr)}}) + } + + c := make(chan *Response) + // TODO: handle resolver nil channel better? + if result == reflect.Zero(result.Type()) { + close(c) + return c + } + + go func() { + for { + // Check subscription context + chosen, resp, ok := reflect.Select([]reflect.SelectCase{ + { + Dir: reflect.SelectRecv, + Chan: reflect.ValueOf(ctx.Done()), + }, + { + Dir: reflect.SelectRecv, + Chan: result, + }, + }) + switch chosen { + // subscription context done + case 0: + close(c) + return + // upstream received + case 1: + // upstream closed + if !ok { + close(c) + return + } + + subR := &Request{ + Request: selected.Request{ + Doc: r.Request.Doc, + Vars: r.Request.Vars, + Schema: r.Request.Schema, + }, + Limiter: r.Limiter, + Tracer: r.Tracer, + Logger: r.Logger, + } + var out bytes.Buffer + func() { + // TODO: configurable timeout + subCtx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + // resolve response + func() { + defer subR.handlePanic(subCtx) + + out.WriteString(fmt.Sprintf(`{"%s":`, f.field.Alias)) + subR.execSelectionSet(subCtx, f.sels, f.field.Type, &pathSegment{nil, f.field.Alias}, resp, &out) + out.WriteString(`}`) + }() + + if err := subCtx.Err(); err != nil { + c <- &Response{Errors: []*errors.QueryError{errors.Errorf("%s", err)}} + return + } + + // Send response within timeout + // TODO: maybe block until sent? + select { + case <-subCtx.Done(): + case c <- &Response{Data: out.Bytes(), Errors: subR.Errs}: + } + }() + } + } + }() + + return c +} + +func sendAndReturnClosed(resp *Response) chan *Response { + c := make(chan *Response, 1) + c <- resp + close(c) + return c +} diff --git a/subscription_test.go b/subscription_test.go new file mode 100644 index 00000000..95a63a2e --- /dev/null +++ b/subscription_test.go @@ -0,0 +1,196 @@ +package graphql_test + +import ( + "context" + "encoding/json" + stdErrors "errors" + "testing" + + graphql "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/errors" + "github.com/graph-gophers/graphql-go/gqltesting" +) + +type rootResolver struct { + *helloResolver + *helloSaidResolver +} + +type helloResolver struct{} + +func (r *helloResolver) Hello() string { + return "Hello world!" +} + +var resolverErr = stdErrors.New("resolver error") + +type helloSaidResolver struct { + err error + upstream <-chan *helloSaidEventResolver +} + +type helloSaidEventResolver struct { + msg string + err error +} + +func (r *helloSaidResolver) HelloSaid(ctx context.Context) (chan *helloSaidEventResolver, error) { + if r.err != nil { + return nil, r.err + } + + c := make(chan *helloSaidEventResolver) + go func() { + for r := range r.upstream { + select { + case <-ctx.Done(): + close(c) + return + case c <- r: + } + } + close(c) + }() + + return c, nil +} + +func (r *helloSaidEventResolver) Msg() (string, error) { + return r.msg, r.err +} + +func closedUpstream(rr ...*helloSaidEventResolver) <-chan *helloSaidEventResolver { + c := make(chan *helloSaidEventResolver, len(rr)) + for _, r := range rr { + c <- r + } + close(c) + return c +} + +func TestSchemaSubscribe(t *testing.T) { + gqltesting.RunSubscribes(t, []*gqltesting.TestSubscription{ + { + Name: "ok", + Schema: graphql.MustParseSchema(schema, &rootResolver{ + helloSaidResolver: &helloSaidResolver{ + upstream: closedUpstream( + &helloSaidEventResolver{msg: "Hello world!"}, + &helloSaidEventResolver{err: resolverErr}, + &helloSaidEventResolver{msg: "Hello again!"}, + ), + }, + }), + Query: ` + subscription onHelloSaid { + helloSaid { + msg + } + } + `, + ExpectedResults: []gqltesting.TestResponse{ + { + Data: json.RawMessage(` + { + "helloSaid": { + "msg": "Hello world!" + } + } + `), + }, + { + Data: json.RawMessage(` + { + "helloSaid": { + "msg":null + } + } + `), + Errors: []*errors.QueryError{errors.Errorf("%s", resolverErr)}, + }, + { + Data: json.RawMessage(` + { + "helloSaid": { + "msg": "Hello again!" + } + } + `), + }, + }, + }, + { + Name: "parse_errors", + Schema: graphql.MustParseSchema(schema, &rootResolver{}), + Query: `invalid graphQL query`, + ExpectedResults: []gqltesting.TestResponse{ + { + Errors: []*errors.QueryError{errors.Errorf("%s", `syntax error: unexpected "invalid", expecting "fragment" (line 1, column 9)`)}, + }, + }, + }, + { + Name: "subscribe_to_query_errors", + Schema: graphql.MustParseSchema(schema, &rootResolver{}), + Query: ` + query Hello { + hello + } + `, + ExpectedResults: []gqltesting.TestResponse{ + { + Errors: []*errors.QueryError{errors.Errorf("%s: %s", "subscription unavailable for operation of type", "QUERY")}, + }, + }, + }, + { + Name: "subscription_resolver_can_error", + Schema: graphql.MustParseSchema(schema, &rootResolver{ + helloSaidResolver: &helloSaidResolver{err: resolverErr}, + }), + Query: ` + subscription onHelloSaid { + helloSaid { + msg + } + } + `, + ExpectedResults: []gqltesting.TestResponse{ + { + Errors: []*errors.QueryError{errors.Errorf("%s", resolverErr)}, + }, + }, + }, + { + Name: "schema_without_resolver_errors", + Schema: &graphql.Schema{}, + Query: ` + subscription onHelloSaid { + helloSaid { + msg + } + } + `, + ExpectedErr: stdErrors.New("schema created without resolver, can not subscribe"), + }, + }) +} + +const schema = ` + schema { + subscription: Subscription, + query: Query + } + + type Subscription { + helloSaid: HelloSaidEvent! + } + + type HelloSaidEvent { + msg: String! + } + + type Query { + hello: String! + } +` diff --git a/subscriptions.go b/subscriptions.go new file mode 100644 index 00000000..4f7aa263 --- /dev/null +++ b/subscriptions.go @@ -0,0 +1,91 @@ +package graphql + +import ( + "context" + stdErrors "errors" + + "github.com/graph-gophers/graphql-go/errors" + "github.com/graph-gophers/graphql-go/internal/common" + "github.com/graph-gophers/graphql-go/internal/exec" + "github.com/graph-gophers/graphql-go/internal/exec/resolvable" + "github.com/graph-gophers/graphql-go/internal/exec/selected" + "github.com/graph-gophers/graphql-go/internal/query" + "github.com/graph-gophers/graphql-go/internal/validation" + "github.com/graph-gophers/graphql-go/introspection" +) + +// Subscribe returns a response channel for the given subscription with the schema's +// resolver. It returns an error if the schema was created without a resolver. +// If the context gets cancelled, the response channel will be closed and no +// further resolvers will be called. The context error will be returned as soon +// as possible (not immediately). +func (s *Schema) Subscribe(ctx context.Context, queryString string, operationName string, variables map[string]interface{}) (<-chan *Response, error) { + if s.res == nil { + return nil, stdErrors.New("schema created without resolver, can not subscribe") + } + return s.subscribe(ctx, queryString, operationName, variables, s.res), nil +} + +func (s *Schema) subscribe(ctx context.Context, queryString string, operationName string, variables map[string]interface{}, res *resolvable.Schema) <-chan *Response { + doc, qErr := query.Parse(queryString) + if qErr != nil { + return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{qErr}}) + } + + validationFinish := s.validationTracer.TraceValidation() + errs := validation.Validate(s.schema, doc, s.maxDepth) + validationFinish(errs) + if len(errs) != 0 { + return sendAndReturnClosed(&Response{Errors: errs}) + } + + op, err := getOperation(doc, operationName) + if err != nil { + return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{errors.Errorf("%s", err)}}) + } + + // TODO: Move to validation.Validate? + if op.Type != query.Subscription { + return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{errors.Errorf("%s: %s", "subscription unavailable for operation of type", op.Type)}}) + } + + r := &exec.Request{ + Request: selected.Request{ + Doc: doc, + Vars: variables, + Schema: s.schema, + }, + Limiter: make(chan struct{}, s.maxParallelism), + Tracer: s.tracer, + Logger: s.logger, + } + varTypes := make(map[string]*introspection.Type) + for _, v := range op.Vars { + t, err := common.ResolveType(v.Type, s.schema.Resolve) + if err != nil { + return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{err}}) + } + varTypes[v.Name.Name] = introspection.WrapType(t) + } + + responses := r.Subscribe(ctx, res, op) + c := make(chan *Response) + go func() { + for resp := range responses { + c <- &Response{ + Data: resp.Data, + Errors: resp.Errors, + } + } + close(c) + }() + + return c +} + +func sendAndReturnClosed(resp *Response) chan *Response { + c := make(chan *Response, 1) + c <- resp + close(c) + return c +} From 3add01de2cb5556d662e49fcdae034f6d60a43c4 Mon Sep 17 00:00:00 2001 From: Matias Anaya Date: Mon, 7 May 2018 20:25:03 +1000 Subject: [PATCH 02/69] Remove stdErrors alias --- subscription_test.go | 16 ++++++++-------- subscriptions.go | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/subscription_test.go b/subscription_test.go index 95a63a2e..58e302da 100644 --- a/subscription_test.go +++ b/subscription_test.go @@ -3,11 +3,11 @@ package graphql_test import ( "context" "encoding/json" - stdErrors "errors" + "errors" "testing" graphql "github.com/graph-gophers/graphql-go" - "github.com/graph-gophers/graphql-go/errors" + qerrors "github.com/graph-gophers/graphql-go/errors" "github.com/graph-gophers/graphql-go/gqltesting" ) @@ -22,7 +22,7 @@ func (r *helloResolver) Hello() string { return "Hello world!" } -var resolverErr = stdErrors.New("resolver error") +var resolverErr = errors.New("resolver error") type helloSaidResolver struct { err error @@ -106,7 +106,7 @@ func TestSchemaSubscribe(t *testing.T) { } } `), - Errors: []*errors.QueryError{errors.Errorf("%s", resolverErr)}, + Errors: []*qerrors.QueryError{qerrors.Errorf("%s", resolverErr)}, }, { Data: json.RawMessage(` @@ -125,7 +125,7 @@ func TestSchemaSubscribe(t *testing.T) { Query: `invalid graphQL query`, ExpectedResults: []gqltesting.TestResponse{ { - Errors: []*errors.QueryError{errors.Errorf("%s", `syntax error: unexpected "invalid", expecting "fragment" (line 1, column 9)`)}, + Errors: []*qerrors.QueryError{qerrors.Errorf("%s", `syntax error: unexpected "invalid", expecting "fragment" (line 1, column 9)`)}, }, }, }, @@ -139,7 +139,7 @@ func TestSchemaSubscribe(t *testing.T) { `, ExpectedResults: []gqltesting.TestResponse{ { - Errors: []*errors.QueryError{errors.Errorf("%s: %s", "subscription unavailable for operation of type", "QUERY")}, + Errors: []*qerrors.QueryError{qerrors.Errorf("%s: %s", "subscription unavailable for operation of type", "QUERY")}, }, }, }, @@ -157,7 +157,7 @@ func TestSchemaSubscribe(t *testing.T) { `, ExpectedResults: []gqltesting.TestResponse{ { - Errors: []*errors.QueryError{errors.Errorf("%s", resolverErr)}, + Errors: []*qerrors.QueryError{qerrors.Errorf("%s", resolverErr)}, }, }, }, @@ -171,7 +171,7 @@ func TestSchemaSubscribe(t *testing.T) { } } `, - ExpectedErr: stdErrors.New("schema created without resolver, can not subscribe"), + ExpectedErr: errors.New("schema created without resolver, can not subscribe"), }, }) } diff --git a/subscriptions.go b/subscriptions.go index 4f7aa263..2c1731bd 100644 --- a/subscriptions.go +++ b/subscriptions.go @@ -2,9 +2,9 @@ package graphql import ( "context" - stdErrors "errors" + "errors" - "github.com/graph-gophers/graphql-go/errors" + qerrors "github.com/graph-gophers/graphql-go/errors" "github.com/graph-gophers/graphql-go/internal/common" "github.com/graph-gophers/graphql-go/internal/exec" "github.com/graph-gophers/graphql-go/internal/exec/resolvable" @@ -21,7 +21,7 @@ import ( // as possible (not immediately). func (s *Schema) Subscribe(ctx context.Context, queryString string, operationName string, variables map[string]interface{}) (<-chan *Response, error) { if s.res == nil { - return nil, stdErrors.New("schema created without resolver, can not subscribe") + return nil, errors.New("schema created without resolver, can not subscribe") } return s.subscribe(ctx, queryString, operationName, variables, s.res), nil } @@ -29,7 +29,7 @@ func (s *Schema) Subscribe(ctx context.Context, queryString string, operationNam func (s *Schema) subscribe(ctx context.Context, queryString string, operationName string, variables map[string]interface{}, res *resolvable.Schema) <-chan *Response { doc, qErr := query.Parse(queryString) if qErr != nil { - return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{qErr}}) + return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{qErr}}) } validationFinish := s.validationTracer.TraceValidation() @@ -41,12 +41,12 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam op, err := getOperation(doc, operationName) if err != nil { - return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{errors.Errorf("%s", err)}}) + return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{qerrors.Errorf("%s", err)}}) } // TODO: Move to validation.Validate? if op.Type != query.Subscription { - return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{errors.Errorf("%s: %s", "subscription unavailable for operation of type", op.Type)}}) + return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{qerrors.Errorf("%s: %s", "subscription unavailable for operation of type", op.Type)}}) } r := &exec.Request{ @@ -63,7 +63,7 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam for _, v := range op.Vars { t, err := common.ResolveType(v.Type, s.schema.Resolve) if err != nil { - return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{err}}) + return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{err}}) } varTypes[v.Name.Name] = introspection.WrapType(t) } From a2b77e9f2dad2ced14b07fd2aae1b71059f159ef Mon Sep 17 00:00:00 2001 From: Matias Anaya Date: Mon, 7 May 2018 20:37:53 +1000 Subject: [PATCH 03/69] Fix graphql indentation --- subscription_test.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/subscription_test.go b/subscription_test.go index 58e302da..20a4d618 100644 --- a/subscription_test.go +++ b/subscription_test.go @@ -84,8 +84,8 @@ func TestSchemaSubscribe(t *testing.T) { Query: ` subscription onHelloSaid { helloSaid { - msg - } + msg + } } `, ExpectedResults: []gqltesting.TestResponse{ @@ -151,8 +151,8 @@ func TestSchemaSubscribe(t *testing.T) { Query: ` subscription onHelloSaid { helloSaid { - msg - } + msg + } } `, ExpectedResults: []gqltesting.TestResponse{ @@ -167,8 +167,8 @@ func TestSchemaSubscribe(t *testing.T) { Query: ` subscription onHelloSaid { helloSaid { - msg - } + msg + } } `, ExpectedErr: errors.New("schema created without resolver, can not subscribe"), @@ -177,18 +177,18 @@ func TestSchemaSubscribe(t *testing.T) { } const schema = ` - schema { - subscription: Subscription, + schema { + subscription: Subscription, query: Query - } + } - type Subscription { - helloSaid: HelloSaidEvent! - } + type Subscription { + helloSaid: HelloSaidEvent! + } - type HelloSaidEvent { - msg: String! - } + type HelloSaidEvent { + msg: String! + } type Query { hello: String! From cb1a8ec3ce857d5e8b77abae4c75942d473a716b Mon Sep 17 00:00:00 2001 From: Matias Anaya Date: Sat, 12 May 2018 07:14:16 +1000 Subject: [PATCH 04/69] Fix formatJSON() call after upstream change --- gqltesting/subscriptions.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/gqltesting/subscriptions.go b/gqltesting/subscriptions.go index 7a8ea43e..ea25514e 100644 --- a/gqltesting/subscriptions.go +++ b/gqltesting/subscriptions.go @@ -69,12 +69,19 @@ func RunSubscribe(t *testing.T, test *TestSubscription) { if err != nil { t.Fatal(err) } - got := formatJSON(t, resData) + got, err := formatJSON(resData) + if err != nil { + t.Fatalf("got: invalid JSON: %s", err) + } + expectedData, err := expected.Data.MarshalJSON() if err != nil { t.Fatal(err) } - want := formatJSON(t, expectedData) + want, err := formatJSON(expectedData) + if err != nil { + t.Fatalf("got: invalid JSON: %s", err) + } if !bytes.Equal(got, want) { t.Logf("got: %s", got) From a8035b80a8d2d643d85261245c9477ad66ff130f Mon Sep 17 00:00:00 2001 From: rick olson Date: Wed, 13 Jun 2018 10:54:50 -0600 Subject: [PATCH 05/69] graphql_test: rename type Enum to make room for another tested enum type --- graphql_test.go | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/graphql_test.go b/graphql_test.go index 1e8b94a7..5e741881 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -1547,11 +1547,11 @@ func TestUnexportedField(t *testing.T) { } } -type Enum string +type StringEnum string const ( - EnumOption1 Enum = "Option1" - EnumOption2 Enum = "Option2" + EnumOption1 StringEnum = "Option1" + EnumOption2 StringEnum = "Option2" ) type inputResolver struct{} @@ -1597,19 +1597,19 @@ func (r *inputResolver) NullableList(args struct{ Value *[]*struct{ V int32 } }) return &l } -func (r *inputResolver) EnumString(args struct{ Value string }) string { +func (r *inputResolver) StringEnumValue(args struct{ Value string }) string { return args.Value } -func (r *inputResolver) NullableEnumString(args struct{ Value *string }) *string { +func (r *inputResolver) NullableStringEnumValue(args struct{ Value *string }) *string { return args.Value } -func (r *inputResolver) Enum(args struct{ Value Enum }) Enum { +func (r *inputResolver) StringEnum(args struct{ Value StringEnum }) StringEnum { return args.Value } -func (r *inputResolver) NullableEnum(args struct{ Value *Enum }) *Enum { +func (r *inputResolver) NullableStringEnum(args struct{ Value *StringEnum }) *StringEnum { return args.Value } @@ -1645,10 +1645,10 @@ func TestInput(t *testing.T) { nullable(value: Int): Int list(value: [Input!]!): [Int!]! nullableList(value: [Input]): [Int] - enumString(value: Enum!): Enum! - nullableEnumString(value: Enum): Enum - enum(value: Enum!): Enum! - nullableEnum(value: Enum): Enum + stringEnumValue(value: StringEnum!): StringEnum! + nullableStringEnumValue(value: StringEnum): StringEnum + stringEnum(value: StringEnum!): StringEnum! + nullableStringEnum(value: StringEnum): StringEnum recursive(value: RecursiveInput!): Int! id(value: ID!): ID! } @@ -1661,7 +1661,7 @@ func TestInput(t *testing.T) { next: RecursiveInput } - enum Enum { + enum StringEnum { Option1 Option2 } @@ -1682,12 +1682,12 @@ func TestInput(t *testing.T) { list2: list(value: {v: 42}) nullableList1: nullableList(value: [{v: 41}, null, {v: 43}]) nullableList2: nullableList(value: null) - enumString(value: Option1) - nullableEnumString1: nullableEnum(value: Option1) - nullableEnumString2: nullableEnum(value: null) - enum(value: Option2) - nullableEnum1: nullableEnum(value: Option2) - nullableEnum2: nullableEnum(value: null) + stringEnumValue(value: Option1) + nullableStringEnumValue1: nullableStringEnum(value: Option1) + nullableStringEnumValue2: nullableStringEnum(value: null) + stringEnum(value: Option2) + nullableStringEnum1: nullableStringEnum(value: Option2) + nullableStringEnum2: nullableStringEnum(value: null) recursive(value: {next: {next: {}}}) intID: id(value: 1234) strID: id(value: "1234") @@ -1706,12 +1706,12 @@ func TestInput(t *testing.T) { "list2": [42], "nullableList1": [41, null, 43], "nullableList2": null, - "enumString": "Option1", - "nullableEnumString1": "Option1", - "nullableEnumString2": null, - "enum": "Option2", - "nullableEnum1": "Option2", - "nullableEnum2": null, + "stringEnumValue": "Option1", + "nullableStringEnumValue1": "Option1", + "nullableStringEnumValue2": null, + "stringEnum": "Option2", + "nullableStringEnum1": "Option2", + "nullableStringEnum2": null, "recursive": 3, "intID": "1234", "strID": "1234" From 69226a02e03db8d4c421e9aceac3aad428106e06 Mon Sep 17 00:00:00 2001 From: rick olson Date: Wed, 13 Jun 2018 11:48:54 -0600 Subject: [PATCH 06/69] internal/exec: Support Stringers as GraphQL enum values. --- graphql_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++ internal/exec/exec.go | 7 +++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/graphql_test.go b/graphql_test.go index 5e741881..0e1e7c3c 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -2,6 +2,7 @@ package graphql_test import ( "context" + "fmt" "testing" "time" @@ -1554,6 +1555,43 @@ const ( EnumOption2 StringEnum = "Option2" ) +type IntEnum int + +const ( + IntEnum0 IntEnum = iota + IntEnum1 +) + +func (e IntEnum) String() string { + switch int(e) { + case 0: + return "Int0" + case 1: + return "Int1" + default: + return "IntN" + } +} + +func (IntEnum) ImplementsGraphQLType(name string) bool { + return name == "IntEnum" +} + +func (e *IntEnum) UnmarshalGraphQL(input interface{}) error { + if str, ok := input.(string); ok { + switch str { + case "Int0": + *e = IntEnum(0) + case "Int1": + *e = IntEnum(1) + default: + *e = IntEnum(-1) + } + return nil + } + return fmt.Errorf("wrong type for IntEnum: %T", input) +} + type inputResolver struct{} func (r *inputResolver) Int(args struct{ Value int32 }) int32 { @@ -1613,6 +1651,22 @@ func (r *inputResolver) NullableStringEnum(args struct{ Value *StringEnum }) *St return args.Value } +func (r *inputResolver) IntEnumValue(args struct{ Value string }) string { + return args.Value +} + +func (r *inputResolver) NullableIntEnumValue(args struct{ Value *string }) *string { + return args.Value +} + +func (r *inputResolver) IntEnum(args struct{ Value IntEnum }) IntEnum { + return args.Value +} + +func (r *inputResolver) NullableIntEnum(args struct{ Value *IntEnum }) *IntEnum { + return args.Value +} + type recursive struct { Next *recursive } @@ -1649,6 +1703,10 @@ func TestInput(t *testing.T) { nullableStringEnumValue(value: StringEnum): StringEnum stringEnum(value: StringEnum!): StringEnum! nullableStringEnum(value: StringEnum): StringEnum + intEnumValue(value: IntEnum!): IntEnum! + nullableIntEnumValue(value: IntEnum): IntEnum + intEnum(value: IntEnum!): IntEnum! + nullableIntEnum(value: IntEnum): IntEnum recursive(value: RecursiveInput!): Int! id(value: ID!): ID! } @@ -1665,6 +1723,11 @@ func TestInput(t *testing.T) { Option1 Option2 } + + enum IntEnum { + Int0 + Int1 + } `, &inputResolver{}) gqltesting.RunTests(t, []*gqltesting.Test{ { @@ -1688,6 +1751,12 @@ func TestInput(t *testing.T) { stringEnum(value: Option2) nullableStringEnum1: nullableStringEnum(value: Option2) nullableStringEnum2: nullableStringEnum(value: null) + intEnumValue(value: Int1) + nullableIntEnumValue1: nullableIntEnumValue(value: Int1) + nullableIntEnumValue2: nullableIntEnumValue(value: null) + intEnum(value: Int1) + nullableIntEnum1: nullableIntEnum(value: Int1) + nullableIntEnum2: nullableIntEnum(value: null) recursive(value: {next: {next: {}}}) intID: id(value: 1234) strID: id(value: "1234") @@ -1712,6 +1781,12 @@ func TestInput(t *testing.T) { "stringEnum": "Option2", "nullableStringEnum1": "Option2", "nullableStringEnum2": null, + "intEnumValue": "Int1", + "nullableIntEnumValue1": "Int1", + "nullableIntEnumValue2": null, + "intEnum": "Int1", + "nullableIntEnum1": "Int1", + "nullableIntEnum2": null, "recursive": 3, "intID": "1234", "strID": "1234" diff --git a/internal/exec/exec.go b/internal/exec/exec.go index f7149619..2453a156 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "reflect" "sync" @@ -275,8 +276,12 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio out.Write(data) case *schema.Enum: + var stringer fmt.Stringer = resolver + if s, ok := resolver.Interface().(fmt.Stringer); ok { + stringer = s + } out.WriteByte('"') - out.WriteString(resolver.String()) + out.WriteString(stringer.String()) out.WriteByte('"') default: From e5aa2734b75836de4efd4f6e7273253155574f3d Mon Sep 17 00:00:00 2001 From: rick olson Date: Wed, 13 Jun 2018 11:27:54 -0600 Subject: [PATCH 07/69] graphql: Add enum output benchmark. --- enum_test.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 enum_test.go diff --git a/enum_test.go b/enum_test.go new file mode 100644 index 00000000..3ad8f4e9 --- /dev/null +++ b/enum_test.go @@ -0,0 +1,52 @@ +package graphql_test + +import ( + "fmt" + "reflect" + "strconv" + "testing" +) + +type benchStringEnum string + +func BenchmarkEnumStringStringer(b *testing.B) { + v := reflect.ValueOf(benchStringEnum("TEST")) + for n := 0; n < b.N; n++ { + if s, ok := v.Interface().(fmt.Stringer); ok { + s.String() + } else { + v.String() + } + } +} + +func BenchmarkEnumStringFmt(b *testing.B) { + v := reflect.ValueOf(benchStringEnum("TEST")) + for n := 0; n < b.N; n++ { + fmt.Sprintf("%s", v) + } +} + +type benchIntEnum int + +func (i benchIntEnum) String() string { + return strconv.Itoa(int(i)) +} + +func BenchmarkEnumIntStringer(b *testing.B) { + v := reflect.ValueOf(benchIntEnum(1)) + for n := 0; n < b.N; n++ { + if s, ok := v.Interface().(fmt.Stringer); ok { + s.String() + } else { + v.String() + } + } +} + +func BenchmarkEnumIntFmt(b *testing.B) { + v := reflect.ValueOf(benchIntEnum(1)) + for n := 0; n < b.N; n++ { + fmt.Sprintf("%s", v) + } +} From 94995566bcd0c09c22f19af4cd5ec230433ac7a0 Mon Sep 17 00:00:00 2001 From: Tom Holmes Date: Thu, 12 Jul 2018 18:45:56 -0700 Subject: [PATCH 08/69] Properly handle nil interface resolvers --- internal/exec/exec.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index f7149619..b6f8574a 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -209,7 +209,8 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio t, nonNull := unwrapNonNull(typ) switch t := t.(type) { case *schema.Object, *schema.Interface, *schema.Union: - if resolver.Kind() == reflect.Ptr && resolver.IsNil() { + // a reflect.Value of a nil interface will show up as an Invalid value + if resolver.Kind() == reflect.Invalid || (resolver.Kind() == reflect.Ptr && resolver.IsNil()) { if nonNull { panic(errors.Errorf("got nil for non-null %q", t)) } From 021755791b94a00ca421743bf71b0e130f0a8aec Mon Sep 17 00:00:00 2001 From: Tom Holmes Date: Thu, 12 Jul 2018 19:03:11 -0700 Subject: [PATCH 09/69] Also handle typed nil --- internal/exec/exec.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index b6f8574a..cb3973f2 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -210,7 +210,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio switch t := t.(type) { case *schema.Object, *schema.Interface, *schema.Union: // a reflect.Value of a nil interface will show up as an Invalid value - if resolver.Kind() == reflect.Invalid || (resolver.Kind() == reflect.Ptr && resolver.IsNil()) { + if resolver.Kind() == reflect.Invalid || ((resolver.Kind() == reflect.Ptr || resolver.Kind() == reflect.Interface) && resolver.IsNil()) { if nonNull { panic(errors.Errorf("got nil for non-null %q", t)) } From cde994fa9b55e8b9ef7cb6d770e3ce14eb2f8341 Mon Sep 17 00:00:00 2001 From: Tom Holmes Date: Fri, 13 Jul 2018 00:43:24 -0700 Subject: [PATCH 10/69] Fill in variables with specified defaults --- graphql.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/graphql.go b/graphql.go index 90779bb5..1f5157f5 100644 --- a/graphql.go +++ b/graphql.go @@ -154,6 +154,13 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str return &Response{Errors: []*errors.QueryError{errors.Errorf("%s", err)}} } + // Fill in variables with the defaults from the operation + for _, v := range op.Vars { + if _, ok := variables[v.Name.Name]; !ok && v.Default != nil { + variables[v.Name.Name] = v.Default.Value(nil) + } + } + r := &exec.Request{ Request: selected.Request{ Doc: doc, From 1d9248e382f9594ccc7b612a47f2adc957282589 Mon Sep 17 00:00:00 2001 From: Tom Holmes Date: Fri, 13 Jul 2018 01:05:03 -0700 Subject: [PATCH 11/69] Handle nil variables map --- graphql.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/graphql.go b/graphql.go index 1f5157f5..33c3667f 100644 --- a/graphql.go +++ b/graphql.go @@ -155,6 +155,9 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str } // Fill in variables with the defaults from the operation + if variables == nil { + variables = make(map[string]interface{}, len(op.Vars)) + } for _, v := range op.Vars { if _, ok := variables[v.Name.Name]; !ok && v.Default != nil { variables[v.Name.Name] = v.Default.Value(nil) From f2ab787cc6c31c5204fbe03a7ea4bc13fc5649d7 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Fri, 21 Sep 2018 05:41:39 +0000 Subject: [PATCH 12/69] reverse order of errors vs data in response --- graphql.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql.go b/graphql.go index 90779bb5..92151c48 100644 --- a/graphql.go +++ b/graphql.go @@ -111,8 +111,8 @@ func Logger(logger log.Logger) SchemaOpt { // Response represents a typical response of a GraphQL server. It may be encoded to JSON directly or // it may be further processed to a custom response type, for example to include custom error data. type Response struct { - Data json.RawMessage `json:"data,omitempty"` Errors []*errors.QueryError `json:"errors,omitempty"` + Data json.RawMessage `json:"data,omitempty"` Extensions map[string]interface{} `json:"extensions,omitempty"` } From 9e8b5cca4093d760acab7ee720acb1524931f31b Mon Sep 17 00:00:00 2001 From: Kirill Danshin Date: Wed, 26 Sep 2018 17:31:40 +0300 Subject: [PATCH 13/69] panic message should me meaningful: invalid type should say which type has an error Signed-off-by: Kirill Danshin --- internal/exec/resolvable/resolvable.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/exec/resolvable/resolvable.go b/internal/exec/resolvable/resolvable.go index b7c1d93d..3e5d9e44 100644 --- a/internal/exec/resolvable/resolvable.go +++ b/internal/exec/resolvable/resolvable.go @@ -173,7 +173,7 @@ func (b *execBuilder) makeExec(t common.Type, resolverType reflect.Type) (Resolv return e, nil default: - panic("invalid type") + panic("invalid type: " + t.String()) } } From 1e3c769ce7dba2175e70ad6a83061f9047e362fa Mon Sep 17 00:00:00 2001 From: Kirsten Schumy Date: Thu, 27 Sep 2018 21:14:17 -0700 Subject: [PATCH 14/69] fixed typos throughout project --- README.md | 2 +- graphql.go | 2 +- internal/common/lexer.go | 2 +- internal/schema/schema.go | 2 +- log/log.go | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7c7c9c7b..ef4b4639 100644 --- a/README.md +++ b/README.md @@ -97,4 +97,4 @@ func (r *helloWorldResolver) Hello(ctx context.Context) (string, error) { [deltaskelta/graphql-go-pets-example](https://github.com/deltaskelta/graphql-go-pets-example) - graphql-go resolving against a sqlite database -[OscarYuen/go-graphql-starter](https://github.com/OscarYuen/go-graphql-starter) - a starter application integrated with dataloader, psql and basic authenication +[OscarYuen/go-graphql-starter](https://github.com/OscarYuen/go-graphql-starter) - a starter application integrated with dataloader, psql and basic authentication diff --git a/graphql.go b/graphql.go index 90779bb5..97c93bb8 100644 --- a/graphql.go +++ b/graphql.go @@ -101,7 +101,7 @@ func ValidationTracer(tracer trace.ValidationTracer) SchemaOpt { } } -// Logger is used to log panics durring query execution. It defaults to exec.DefaultLogger. +// Logger is used to log panics during query execution. It defaults to exec.DefaultLogger. func Logger(logger log.Logger) SchemaOpt { return func(s *Schema) { s.logger = logger diff --git a/internal/common/lexer.go b/internal/common/lexer.go index 7b0dd755..a38fcbaf 100644 --- a/internal/common/lexer.go +++ b/internal/common/lexer.go @@ -63,7 +63,7 @@ func (l *Lexer) Consume() { if l.next == ',' { // Similar to white space and line terminators, commas (',') are used to improve the // legibility of source text and separate lexical tokens but are otherwise syntactically and - // semanitcally insignificant within GraphQL documents. + // semantically insignificant within GraphQL documents. // // http://facebook.github.io/graphql/draft/#sec-Insignificant-Commas continue diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 62d6de12..e549f17c 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -163,7 +163,7 @@ type InputObject struct { type FieldList []*Field // Get iterates over the field list, returning a pointer-to-Field when the field name matches the -// provided `name` arguement. +// provided `name` argument. // Returns nil when no field was found by that name. func (l FieldList) Get(name string) *Field { for _, f := range l { diff --git a/log/log.go b/log/log.go index aaab4342..25569af7 100644 --- a/log/log.go +++ b/log/log.go @@ -6,15 +6,15 @@ import ( "runtime" ) -// Logger is the interface used to log panics that occur durring query execution. It is setable via graphql.ParseSchema +// Logger is the interface used to log panics that occur during query execution. It is settable via graphql.ParseSchema type Logger interface { LogPanic(ctx context.Context, value interface{}) } -// DefaultLogger is the default logger used to log panics that occur durring query execution +// DefaultLogger is the default logger used to log panics that occur during query execution type DefaultLogger struct{} -// LogPanic is used to log recovered panic values that occur durring query execution +// LogPanic is used to log recovered panic values that occur during query execution func (l *DefaultLogger) LogPanic(_ context.Context, value interface{}) { const size = 64 << 10 buf := make([]byte, size) From 18072e1cdee0b74fb5d858344404770c7fc21143 Mon Sep 17 00:00:00 2001 From: Quinn Slack Date: Fri, 28 Sep 2018 23:51:41 -0700 Subject: [PATCH 15/69] add test for nil interface resolvers Prior to the fix, the new TestNilInterface test would fail with: graphql: panic occurred: reflect: Method on nil interface value --- graphql_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/graphql_test.go b/graphql_test.go index 1e8b94a7..e1423eea 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -2,10 +2,12 @@ package graphql_test import ( "context" + "errors" "testing" "time" "github.com/graph-gophers/graphql-go" + graphqlerrors "github.com/graph-gophers/graphql-go/errors" "github.com/graph-gophers/graphql-go/example/starwars" "github.com/graph-gophers/graphql-go/gqltesting" ) @@ -245,6 +247,63 @@ func TestBasic(t *testing.T) { }) } +type testNilInterfaceResolver struct{} + +func (r *testNilInterfaceResolver) A() interface{ Z() int32 } { + return nil +} + +func (r *testNilInterfaceResolver) B() (interface{ Z() int32 }, error) { + return nil, errors.New("x") +} + +func (r *testNilInterfaceResolver) C() (interface{ Z() int32 }, error) { + return nil, nil +} + +func TestNilInterface(t *testing.T) { + gqltesting.RunTests(t, []*gqltesting.Test{ + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + a: T + b: T + c: T + } + + type T { + z: Int! + } + `, &testNilInterfaceResolver{}), + Query: ` + { + a { z } + b { z } + c { z } + } + `, + ExpectedResult: ` + { + "a": null, + "b": null, + "c": null + } + `, + ExpectedErrors: []*graphqlerrors.QueryError{ + &graphqlerrors.QueryError{ + Message: "x", + Path: []interface{}{"b"}, + ResolverError: errors.New("x"), + }, + }, + }, + }) +} + func TestArguments(t *testing.T) { gqltesting.RunTests(t, []*gqltesting.Test{ { From 45ec3c93ba2d81d280fd46513d217806d3af434f Mon Sep 17 00:00:00 2001 From: Tom Holmes Date: Mon, 1 Oct 2018 13:51:51 -0700 Subject: [PATCH 16/69] Add test --- graphql_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/graphql_test.go b/graphql_test.go index 1e8b94a7..bbe56f9d 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -61,6 +61,12 @@ func (r *timeResolver) AddHour(args struct{ Time graphql.Time }) graphql.Time { return graphql.Time{Time: args.Time.Add(time.Hour)} } +type echoResolver struct{} + +func (r *echoResolver) Echo(args struct{ Value *string }) *string { + return args.Value +} + var starwarsSchema = graphql.MustParseSchema(starwars.Schema, &starwars.Resolver{}) func TestHelloWorld(t *testing.T) { @@ -438,6 +444,28 @@ func TestVariables(t *testing.T) { } `, }, + + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + echo(value: String): String + } + `, &echoResolver{}), + Query: ` + query Echo($value:String = "default"){ + echo(value:$value) + } + `, + ExpectedResult: ` + { + "echo": "default" + } + `, + }, }) } From ad74b2bf20a035eae2eb8ca477318ed27416c58a Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Tue, 2 Oct 2018 15:49:14 +0000 Subject: [PATCH 17/69] add a comment --- graphql.go | 1 + 1 file changed, 1 insertion(+) diff --git a/graphql.go b/graphql.go index 92151c48..f235c786 100644 --- a/graphql.go +++ b/graphql.go @@ -110,6 +110,7 @@ func Logger(logger log.Logger) SchemaOpt { // Response represents a typical response of a GraphQL server. It may be encoded to JSON directly or // it may be further processed to a custom response type, for example to include custom error data. +// Errors are intentionally serialized first based on the advice in https://github.com/facebook/graphql/commit/7b40390d48680b15cb93e02d46ac5eb249689876#diff-757cea6edf0288677a9eea4cfc801d87R107 type Response struct { Errors []*errors.QueryError `json:"errors,omitempty"` Data json.RawMessage `json:"data,omitempty"` From 634acfdd6d34408061a1588cae4d7a14727786e8 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Wed, 3 Oct 2018 03:51:55 +0000 Subject: [PATCH 18/69] parse string based descriptions --- internal/common/lexer.go | 67 +++++++++++++++++++++++-- internal/common/lexer_test.go | 11 ++-- internal/query/query.go | 2 +- internal/schema/schema.go | 2 +- internal/schema/schema_internal_test.go | 4 +- 5 files changed, 74 insertions(+), 12 deletions(-) diff --git a/internal/common/lexer.go b/internal/common/lexer.go index a38fcbaf..97cdbb97 100644 --- a/internal/common/lexer.go +++ b/internal/common/lexer.go @@ -2,6 +2,7 @@ package common import ( "fmt" + "strconv" "strings" "text/scanner" @@ -55,7 +56,7 @@ func (l *Lexer) Peek() rune { // Consumed comment characters will build the description for the next type or field encountered. // The description is available from `DescComment()`, and will be reset every time `Consume()` is // executed. -func (l *Lexer) Consume() { +func (l *Lexer) Consume(allowNewStyleDescription bool) { l.descComment = "" for { l.next = l.sc.Scan() @@ -69,6 +70,24 @@ func (l *Lexer) Consume() { continue } + if l.next == scanner.String && allowNewStyleDescription { + // Instead of comments, strings are used to encode descriptions in the June 2018 graphql spec. + // For now we handle both, but in the future we must provide a way to disable including comments in + // descriptions to become fully spec compatible. + // http://facebook.github.io/graphql/June2018/#sec-Descriptions + + // a triple quote string is an empty "string" followed by an open quote due to the way the parser treats strings as one token + tokenText := l.sc.TokenText() + if l.sc.Peek() == '"' { + // Consume the third quote + l.next = l.sc.Next() + l.consumeTripleQuoteComment() + continue + } + l.consumeStringComment(tokenText) + continue + } + if l.next == '#' { // GraphQL source documents may contain single-line comments, starting with the '#' marker. // @@ -101,12 +120,12 @@ func (l *Lexer) ConsumeKeyword(keyword string) { if l.next != scanner.Ident || l.sc.TokenText() != keyword { l.SyntaxError(fmt.Sprintf("unexpected %q, expecting %q", l.sc.TokenText(), keyword)) } - l.Consume() + l.Consume(true) } func (l *Lexer) ConsumeLiteral() *BasicLit { lit := &BasicLit{Type: l.next, Text: l.sc.TokenText()} - l.Consume() + l.Consume(true) return lit } @@ -114,7 +133,7 @@ func (l *Lexer) ConsumeToken(expected rune) { if l.next != expected { l.SyntaxError(fmt.Sprintf("unexpected %q, expecting %s", l.sc.TokenText(), scanner.TokenString(expected))) } - l.Consume() + l.Consume(false) } func (l *Lexer) DescComment() string { @@ -132,11 +151,49 @@ func (l *Lexer) Location() errors.Location { } } +func (l *Lexer) consumeTripleQuoteComment() { + if l.next != '"' { + panic("consumeTripleQuoteComment used in wrong context: no third quote?") + } + + if l.descComment != "" { + l.descComment += "\n" + } + + comment := "" + numQuotes := 0 + for { + next := l.sc.Next() + if next == '"' { + numQuotes++ + } else { + numQuotes = 0 + } + comment += string(next) + if numQuotes == 3 || next == scanner.EOF { + break + } + } + l.descComment += strings.TrimSpace(comment[:len(comment)-numQuotes]) +} + +func (l *Lexer) consumeStringComment(str string) { + if l.descComment != "" { + l.descComment += "\n" + } + + value, err := strconv.Unquote(str) + if err != nil { + panic(err) + } + l.descComment += value +} + // consumeComment consumes all characters from `#` to the first encountered line terminator. // The characters are appended to `l.descComment`. func (l *Lexer) consumeComment() { if l.next != '#' { - return + panic("consumeComment used in wrong context") } // TODO: count and trim whitespace so we can dedent any following lines. diff --git a/internal/common/lexer_test.go b/internal/common/lexer_test.go index 4f811f7f..947dde7c 100644 --- a/internal/common/lexer_test.go +++ b/internal/common/lexer_test.go @@ -17,12 +17,17 @@ var consumeTests = []consumeTestCase{{ definition: ` # Comment line 1 -# Comment line 2 +#Comment line 2 ,,,,,, # Commas are insignificant +"New style comments" +"" +""" +so " many comments +""" type Hello { world: String! }`, - expected: "Comment line 1\nComment line 2\nCommas are insignificant", + expected: "Comment line 1\nComment line 2\nCommas are insignificant\nNew style comments\n\nso \" many comments", }} func TestConsume(t *testing.T) { @@ -30,7 +35,7 @@ func TestConsume(t *testing.T) { t.Run(test.description, func(t *testing.T) { lex := common.NewLexer(test.definition) - err := lex.CatchSyntaxError(lex.Consume) + err := lex.CatchSyntaxError(func() { lex.Consume(true) }) if err != nil { t.Fatal(err) } diff --git a/internal/query/query.go b/internal/query/query.go index faba4d2a..b8477587 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -107,7 +107,7 @@ func Parse(queryString string) (*Document, *errors.QueryError) { func parseDocument(l *common.Lexer) *Document { d := &Document{} - l.Consume() + l.Consume(true) for l.Peek() != scanner.EOF { if l.Peek() == '{' { op := &Operation{Type: Query, Loc: l.Location()} diff --git a/internal/schema/schema.go b/internal/schema/schema.go index e549f17c..11c65e0a 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -389,7 +389,7 @@ func resolveInputObject(s *Schema, values common.InputValueList) error { } func parseSchema(s *Schema, l *common.Lexer) { - l.Consume() + l.Consume(true) for l.Peek() != scanner.EOF { desc := l.DescComment() diff --git a/internal/schema/schema_internal_test.go b/internal/schema/schema_internal_test.go index 9159eeec..1d651858 100644 --- a/internal/schema/schema_internal_test.go +++ b/internal/schema/schema_internal_test.go @@ -18,7 +18,7 @@ func TestParseInterfaceDef(t *testing.T) { tests := []testCase{{ description: "Parses simple interface", definition: "Greeting { field: String }", - expected: &Interface{Name: "Greeting", Fields: []*Field{&Field{Name: "field"}}}, + expected: &Interface{Name: "Greeting", Fields: []*Field{{Name: "field"}}}, }} for _, test := range tests { @@ -159,7 +159,7 @@ func setup(t *testing.T, def string) *common.Lexer { t.Helper() lex := common.NewLexer(def) - lex.Consume() + lex.Consume(true) return lex } From f63fb7924b5da36535019d9e3fffa112e34f0775 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Wed, 3 Oct 2018 04:09:07 +0000 Subject: [PATCH 19/69] add an option to handle comments according to the new spec --- graphql.go | 20 ++++++++---- internal/common/lexer.go | 26 +++++++++------ internal/common/lexer_test.go | 32 +++++++++++++++---- internal/query/query.go | 2 +- internal/schema/meta.go | 4 +-- internal/schema/schema.go | 4 +-- internal/schema/schema_internal_test.go | 2 +- internal/schema/schema_test.go | 2 +- .../validation/validate_max_depth_test.go | 10 +++--- internal/validation/validation_test.go | 2 +- 10 files changed, 69 insertions(+), 35 deletions(-) diff --git a/graphql.go b/graphql.go index 06ffd459..92bf30d4 100644 --- a/graphql.go +++ b/graphql.go @@ -34,7 +34,7 @@ func ParseSchema(schemaString string, resolver interface{}, opts ...SchemaOpt) ( opt(s) } - if err := s.schema.Parse(schemaString); err != nil { + if err := s.schema.Parse(schemaString, s.noCommentsAsDescriptions); err != nil { return nil, err } @@ -63,16 +63,24 @@ type Schema struct { schema *schema.Schema res *resolvable.Schema - maxDepth int - maxParallelism int - tracer trace.Tracer - validationTracer trace.ValidationTracer - logger log.Logger + maxDepth int + maxParallelism int + tracer trace.Tracer + validationTracer trace.ValidationTracer + logger log.Logger + noCommentsAsDescriptions bool } // SchemaOpt is an option to pass to ParseSchema or MustParseSchema. type SchemaOpt func(*Schema) +// NoCommentsAsDescriptions disables the parsing of comments as descriptions +func NoCommentsAsDescriptions() SchemaOpt { + return func(s *Schema) { + s.noCommentsAsDescriptions = true + } +} + // MaxDepth specifies the maximum field nesting depth in a query. The default is 0 which disables max depth checking. func MaxDepth(n int) SchemaOpt { return func(s *Schema) { diff --git a/internal/common/lexer.go b/internal/common/lexer.go index 97cdbb97..75ef191e 100644 --- a/internal/common/lexer.go +++ b/internal/common/lexer.go @@ -12,9 +12,10 @@ import ( type syntaxError string type Lexer struct { - sc *scanner.Scanner - next rune - descComment string + sc *scanner.Scanner + next rune + descComment string + noCommentsAsDescriptions bool } type Ident struct { @@ -22,13 +23,13 @@ type Ident struct { Loc errors.Location } -func NewLexer(s string) *Lexer { +func NewLexer(s string, noCommentsAsDescriptions bool) *Lexer { sc := &scanner.Scanner{ Mode: scanner.ScanIdents | scanner.ScanInts | scanner.ScanFloats | scanner.ScanStrings, } sc.Init(strings.NewReader(s)) - return &Lexer{sc: sc} + return &Lexer{sc: sc, noCommentsAsDescriptions: noCommentsAsDescriptions} } func (l *Lexer) CatchSyntaxError(f func()) (errRes *errors.QueryError) { @@ -72,8 +73,11 @@ func (l *Lexer) Consume(allowNewStyleDescription bool) { if l.next == scanner.String && allowNewStyleDescription { // Instead of comments, strings are used to encode descriptions in the June 2018 graphql spec. - // For now we handle both, but in the future we must provide a way to disable including comments in - // descriptions to become fully spec compatible. + // We can handle both, but there's an option to disable the old comment based descriptions and treat comments + // as comments. + // Single quote strings are also single line. Triple quote strings can be multi-line. Triple quote strings + // whitespace trimmed on both ends. + // // http://facebook.github.io/graphql/June2018/#sec-Descriptions // a triple quote string is an empty "string" followed by an open quote due to the way the parser treats strings as one token @@ -201,7 +205,7 @@ func (l *Lexer) consumeComment() { l.sc.Next() } - if l.descComment != "" { + if l.descComment != "" && !l.noCommentsAsDescriptions { // TODO: use a bytes.Buffer or strings.Builder instead of this. l.descComment += "\n" } @@ -212,7 +216,9 @@ func (l *Lexer) consumeComment() { break } - // TODO: use a bytes.Buffer or strings.Build instead of this. - l.descComment += string(next) + if !l.noCommentsAsDescriptions { + // TODO: use a bytes.Buffer or strings.Build instead of this. + l.descComment += string(next) + } } } diff --git a/internal/common/lexer_test.go b/internal/common/lexer_test.go index 947dde7c..9426ccf3 100644 --- a/internal/common/lexer_test.go +++ b/internal/common/lexer_test.go @@ -7,9 +7,10 @@ import ( ) type consumeTestCase struct { - description string - definition string - expected string // expected description + description string + definition string + expected string // expected description + noCommentsAsDescriptions bool } var consumeTests = []consumeTestCase{{ @@ -27,13 +28,32 @@ so " many comments type Hello { world: String! }`, - expected: "Comment line 1\nComment line 2\nCommas are insignificant\nNew style comments\n\nso \" many comments", -}} + expected: "Comment line 1\nComment line 2\nCommas are insignificant\nNew style comments\n\nso \" many comments", + noCommentsAsDescriptions: false, +}, + { + description: "initial test", + definition: ` + +# Comment line 1 +#Comment line 2 +,,,,,, # Commas are insignificant +"New style comments" +"" +""" +so " many comments +""" +type Hello { + world: String! +}`, + expected: "New style comments\n\nso \" many comments", + noCommentsAsDescriptions: true, + }} func TestConsume(t *testing.T) { for _, test := range consumeTests { t.Run(test.description, func(t *testing.T) { - lex := common.NewLexer(test.definition) + lex := common.NewLexer(test.definition, test.noCommentsAsDescriptions) err := lex.CatchSyntaxError(func() { lex.Consume(true) }) if err != nil { diff --git a/internal/query/query.go b/internal/query/query.go index b8477587..f73cf0f5 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -94,7 +94,7 @@ func (InlineFragment) isSelection() {} func (FragmentSpread) isSelection() {} func Parse(queryString string) (*Document, *errors.QueryError) { - l := common.NewLexer(queryString) + l := common.NewLexer(queryString, false) var doc *Document err := l.CatchSyntaxError(func() { doc = parseDocument(l) }) diff --git a/internal/schema/meta.go b/internal/schema/meta.go index b48bf7ac..365e740a 100644 --- a/internal/schema/meta.go +++ b/internal/schema/meta.go @@ -5,7 +5,7 @@ var Meta *Schema func init() { Meta = &Schema{} // bootstrap Meta = New() - if err := Meta.Parse(metaSrc); err != nil { + if err := Meta.Parse(metaSrc, false); err != nil { panic(err) } } @@ -167,7 +167,7 @@ var metaSrc = ` inputFields: [__InputValue!] ofType: __Type } - + # An enum describing what kind of type a given ` + "`" + `__Type` + "`" + ` is. enum __TypeKind { # Indicates this type is a scalar. diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 11c65e0a..0612fde1 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -246,8 +246,8 @@ func New() *Schema { } // Parse the schema string. -func (s *Schema) Parse(schemaString string) error { - l := common.NewLexer(schemaString) +func (s *Schema) Parse(schemaString string, noCommentsAsDescriptions bool) error { + l := common.NewLexer(schemaString, noCommentsAsDescriptions) err := l.CatchSyntaxError(func() { parseSchema(s, l) }) if err != nil { diff --git a/internal/schema/schema_internal_test.go b/internal/schema/schema_internal_test.go index 1d651858..56b2730c 100644 --- a/internal/schema/schema_internal_test.go +++ b/internal/schema/schema_internal_test.go @@ -158,7 +158,7 @@ func compareObjects(t *testing.T, expected, actual *Object) { func setup(t *testing.T, def string) *common.Lexer { t.Helper() - lex := common.NewLexer(def) + lex := common.NewLexer(def, false) lex.Consume(true) return lex diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index e656fabf..5ee5156d 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -80,7 +80,7 @@ func TestParse(t *testing.T) { t.Skip("TODO: add support for descriptions") schema := setup(t) - err := schema.Parse(test.sdl) + err := schema.Parse(test.sdl, false) if err != nil { t.Fatal(err) } diff --git a/internal/validation/validate_max_depth_test.go b/internal/validation/validate_max_depth_test.go index 9fa74b0b..4dc13e66 100644 --- a/internal/validation/validate_max_depth_test.go +++ b/internal/validation/validate_max_depth_test.go @@ -105,7 +105,7 @@ func (tc maxDepthTestCase) Run(t *testing.T, s *schema.Schema) { func TestMaxDepth(t *testing.T) { s := schema.New() - err := s.Parse(simpleSchema) + err := s.Parse(simpleSchema, false) if err != nil { t.Fatal(err) } @@ -181,7 +181,7 @@ func TestMaxDepth(t *testing.T) { func TestMaxDepthInlineFragments(t *testing.T) { s := schema.New() - err := s.Parse(interfaceSimple) + err := s.Parse(interfaceSimple, false) if err != nil { t.Fatal(err) } @@ -230,7 +230,7 @@ func TestMaxDepthInlineFragments(t *testing.T) { func TestMaxDepthFragmentSpreads(t *testing.T) { s := schema.New() - err := s.Parse(interfaceSimple) + err := s.Parse(interfaceSimple, false) if err != nil { t.Fatal(err) } @@ -317,7 +317,7 @@ func TestMaxDepthFragmentSpreads(t *testing.T) { func TestMaxDepthUnknownFragmentSpreads(t *testing.T) { s := schema.New() - err := s.Parse(interfaceSimple) + err := s.Parse(interfaceSimple, false) if err != nil { t.Fatal(err) } @@ -352,7 +352,7 @@ func TestMaxDepthUnknownFragmentSpreads(t *testing.T) { func TestMaxDepthValidation(t *testing.T) { s := schema.New() - err := s.Parse(interfaceSimple) + err := s.Parse(interfaceSimple, false) if err != nil { t.Fatal(err) } diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go index a2bf6141..52b6f2c6 100644 --- a/internal/validation/validation_test.go +++ b/internal/validation/validation_test.go @@ -39,7 +39,7 @@ func TestValidate(t *testing.T) { schemas := make([]*schema.Schema, len(testData.Schemas)) for i, schemaStr := range testData.Schemas { schemas[i] = schema.New() - if err := schemas[i].Parse(schemaStr); err != nil { + if err := schemas[i].Parse(schemaStr, false); err != nil { t.Fatal(err) } } From 98cfed735c13f60775f4c9eb4f559e8cd9275f0e Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Wed, 3 Oct 2018 04:46:34 +0000 Subject: [PATCH 20/69] fix bug with eager string consumption --- internal/common/lexer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/common/lexer.go b/internal/common/lexer.go index 75ef191e..34887cdb 100644 --- a/internal/common/lexer.go +++ b/internal/common/lexer.go @@ -129,7 +129,7 @@ func (l *Lexer) ConsumeKeyword(keyword string) { func (l *Lexer) ConsumeLiteral() *BasicLit { lit := &BasicLit{Type: l.next, Text: l.sc.TokenText()} - l.Consume(true) + l.Consume(false) return lit } From d79e178f36c0856991201a1e6b2b65e2927458e5 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Wed, 3 Oct 2018 17:29:44 +0000 Subject: [PATCH 21/69] fix mixed comment / description handling --- internal/common/lexer.go | 77 ++++++++++++++----------- internal/common/lexer_test.go | 50 ++++++++++------ internal/query/query.go | 2 +- internal/schema/schema.go | 2 +- internal/schema/schema_internal_test.go | 2 +- 5 files changed, 79 insertions(+), 54 deletions(-) diff --git a/internal/common/lexer.go b/internal/common/lexer.go index 34887cdb..e5d40aae 100644 --- a/internal/common/lexer.go +++ b/internal/common/lexer.go @@ -52,13 +52,15 @@ func (l *Lexer) Peek() rune { return l.next } -// Consume whitespace and tokens equivalent to whitespace (e.g. commas and comments). +// ConsumeWhitespace consumes whitespace and tokens equivalent to whitespace (e.g. commas and comments). // // Consumed comment characters will build the description for the next type or field encountered. -// The description is available from `DescComment()`, and will be reset every time `Consume()` is -// executed. -func (l *Lexer) Consume(allowNewStyleDescription bool) { - l.descComment = "" +// The description is available from `DescComment()`, and will be reset every time `ConsumeWhitespace()` is +// executed unless l.noCommentsAsDescriptions is set. +func (l *Lexer) ConsumeWhitespace() { + if !l.noCommentsAsDescriptions { + l.descComment = "" + } for { l.next = l.sc.Scan() @@ -71,27 +73,6 @@ func (l *Lexer) Consume(allowNewStyleDescription bool) { continue } - if l.next == scanner.String && allowNewStyleDescription { - // Instead of comments, strings are used to encode descriptions in the June 2018 graphql spec. - // We can handle both, but there's an option to disable the old comment based descriptions and treat comments - // as comments. - // Single quote strings are also single line. Triple quote strings can be multi-line. Triple quote strings - // whitespace trimmed on both ends. - // - // http://facebook.github.io/graphql/June2018/#sec-Descriptions - - // a triple quote string is an empty "string" followed by an open quote due to the way the parser treats strings as one token - tokenText := l.sc.TokenText() - if l.sc.Peek() == '"' { - // Consume the third quote - l.next = l.sc.Next() - l.consumeTripleQuoteComment() - continue - } - l.consumeStringComment(tokenText) - continue - } - if l.next == '#' { // GraphQL source documents may contain single-line comments, starting with the '#' marker. // @@ -107,6 +88,31 @@ func (l *Lexer) Consume(allowNewStyleDescription bool) { } } +// consumeDescription optionally consumes a description based on the June 2018 graphql spec if any are present. +// +// Single quote strings are also single line. Triple quote strings can be multi-line. Triple quote strings +// whitespace trimmed on both ends. +// If a description is found, consume any following comments as well +// +// http://facebook.github.io/graphql/June2018/#sec-Descriptions +func (l *Lexer) consumeDescription() bool { + // If the next token is not a string, we don't consume it + if l.next == scanner.String { + // a triple quote string is an empty "string" followed by an open quote due to the way the parser treats strings as one token + l.descComment = "" + tokenText := l.sc.TokenText() + if l.sc.Peek() == '"' { + // Consume the third quote + l.next = l.sc.Next() + l.consumeTripleQuoteComment() + } else { + l.consumeStringComment(tokenText) + } + return true + } + return false +} + func (l *Lexer) ConsumeIdent() string { name := l.sc.TokenText() l.ConsumeToken(scanner.Ident) @@ -124,12 +130,12 @@ func (l *Lexer) ConsumeKeyword(keyword string) { if l.next != scanner.Ident || l.sc.TokenText() != keyword { l.SyntaxError(fmt.Sprintf("unexpected %q, expecting %q", l.sc.TokenText(), keyword)) } - l.Consume(true) + l.ConsumeWhitespace() } func (l *Lexer) ConsumeLiteral() *BasicLit { lit := &BasicLit{Type: l.next, Text: l.sc.TokenText()} - l.Consume(false) + l.ConsumeWhitespace() return lit } @@ -137,10 +143,15 @@ func (l *Lexer) ConsumeToken(expected rune) { if l.next != expected { l.SyntaxError(fmt.Sprintf("unexpected %q, expecting %s", l.sc.TokenText(), scanner.TokenString(expected))) } - l.Consume(false) + l.ConsumeWhitespace() } func (l *Lexer) DescComment() string { + if l.noCommentsAsDescriptions { + if l.consumeDescription() { + l.ConsumeWhitespace() + } + } return l.descComment } @@ -167,14 +178,14 @@ func (l *Lexer) consumeTripleQuoteComment() { comment := "" numQuotes := 0 for { - next := l.sc.Next() - if next == '"' { + l.next = l.sc.Next() + if l.next == '"' { numQuotes++ } else { numQuotes = 0 } - comment += string(next) - if numQuotes == 3 || next == scanner.EOF { + comment += string(l.next) + if numQuotes == 3 || l.next == scanner.EOF { break } } diff --git a/internal/common/lexer_test.go b/internal/common/lexer_test.go index 9426ccf3..fe1e12ab 100644 --- a/internal/common/lexer_test.go +++ b/internal/common/lexer_test.go @@ -10,54 +10,68 @@ type consumeTestCase struct { description string definition string expected string // expected description + failureExpected bool noCommentsAsDescriptions bool } +// Note that these tests stop as soon as they parse the comments, so even though the rest of the file will fail to parse sometimes, the tests still pass var consumeTests = []consumeTestCase{{ - description: "initial test", + description: "no string descriptions allowed in old mode", definition: ` # Comment line 1 #Comment line 2 ,,,,,, # Commas are insignificant "New style comments" -"" -""" -so " many comments -""" type Hello { world: String! }`, - expected: "Comment line 1\nComment line 2\nCommas are insignificant\nNew style comments\n\nso \" many comments", + expected: "Comment line 1\nComment line 2\nCommas are insignificant", noCommentsAsDescriptions: false, -}, - { - description: "initial test", - definition: ` +}, { + description: "simple string descriptions allowed in old mode", + definition: ` # Comment line 1 #Comment line 2 ,,,,,, # Commas are insignificant "New style comments" -"" +type Hello { + world: String! +}`, + expected: "New style comments", + noCommentsAsDescriptions: true, +}, { + description: "triple quote descriptions allowed in old mode", + definition: ` + +# Comment line 1 +#Comment line 2 +,,,,,, # Commas are insignificant """ -so " many comments +New style comments """ type Hello { world: String! }`, - expected: "New style comments\n\nso \" many comments", - noCommentsAsDescriptions: true, - }} + expected: "New style comments", + noCommentsAsDescriptions: true, +}} func TestConsume(t *testing.T) { for _, test := range consumeTests { t.Run(test.description, func(t *testing.T) { lex := common.NewLexer(test.definition, test.noCommentsAsDescriptions) - err := lex.CatchSyntaxError(func() { lex.Consume(true) }) - if err != nil { - t.Fatal(err) + err := lex.CatchSyntaxError(func() { lex.ConsumeWhitespace() }) + if test.failureExpected { + if err == nil { + t.Fatalf("schema should have been invalid; comment: %s", lex.DescComment()) + } + } else { + if err != nil { + t.Fatal(err) + } } if test.expected != lex.DescComment() { diff --git a/internal/query/query.go b/internal/query/query.go index f73cf0f5..fffc88e7 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -107,7 +107,7 @@ func Parse(queryString string) (*Document, *errors.QueryError) { func parseDocument(l *common.Lexer) *Document { d := &Document{} - l.Consume(true) + l.ConsumeWhitespace() for l.Peek() != scanner.EOF { if l.Peek() == '{' { op := &Operation{Type: Query, Loc: l.Location()} diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 0612fde1..a8f8c7eb 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -389,7 +389,7 @@ func resolveInputObject(s *Schema, values common.InputValueList) error { } func parseSchema(s *Schema, l *common.Lexer) { - l.Consume(true) + l.ConsumeWhitespace() for l.Peek() != scanner.EOF { desc := l.DescComment() diff --git a/internal/schema/schema_internal_test.go b/internal/schema/schema_internal_test.go index 56b2730c..d652f5d5 100644 --- a/internal/schema/schema_internal_test.go +++ b/internal/schema/schema_internal_test.go @@ -159,7 +159,7 @@ func setup(t *testing.T, def string) *common.Lexer { t.Helper() lex := common.NewLexer(def, false) - lex.Consume(true) + lex.ConsumeWhitespace() return lex } From 71460bc6a32a76b88086f36083a4d91122e33118 Mon Sep 17 00:00:00 2001 From: Tom Holmes Date: Sun, 7 Oct 2018 16:05:38 -0700 Subject: [PATCH 22/69] graphqlerrors -> gqlerrors --- graphql_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphql_test.go b/graphql_test.go index e1423eea..4de202a6 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/graph-gophers/graphql-go" - graphqlerrors "github.com/graph-gophers/graphql-go/errors" + gqlerrors "github.com/graph-gophers/graphql-go/errors" "github.com/graph-gophers/graphql-go/example/starwars" "github.com/graph-gophers/graphql-go/gqltesting" ) @@ -293,8 +293,8 @@ func TestNilInterface(t *testing.T) { "c": null } `, - ExpectedErrors: []*graphqlerrors.QueryError{ - &graphqlerrors.QueryError{ + ExpectedErrors: []*gqlerrors.QueryError{ + &gqlerrors.QueryError{ Message: "x", Path: []interface{}{"b"}, ResolverError: errors.New("x"), From b1ba9ff5476c48a25a037bbc44b5c0dfbf422878 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Mon, 15 Oct 2018 18:55:35 +0000 Subject: [PATCH 23/69] rename NoCommentsAsDesciptions to UseStringDescriptions --- graphql.go | 20 ++++++++++---------- internal/common/lexer.go | 22 +++++++++++----------- internal/common/lexer_test.go | 24 ++++++++++++------------ internal/schema/schema.go | 4 ++-- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/graphql.go b/graphql.go index 92bf30d4..f102b097 100644 --- a/graphql.go +++ b/graphql.go @@ -34,7 +34,7 @@ func ParseSchema(schemaString string, resolver interface{}, opts ...SchemaOpt) ( opt(s) } - if err := s.schema.Parse(schemaString, s.noCommentsAsDescriptions); err != nil { + if err := s.schema.Parse(schemaString, s.useStringDescriptions); err != nil { return nil, err } @@ -63,21 +63,21 @@ type Schema struct { schema *schema.Schema res *resolvable.Schema - maxDepth int - maxParallelism int - tracer trace.Tracer - validationTracer trace.ValidationTracer - logger log.Logger - noCommentsAsDescriptions bool + maxDepth int + maxParallelism int + tracer trace.Tracer + validationTracer trace.ValidationTracer + logger log.Logger + useStringDescriptions bool } // SchemaOpt is an option to pass to ParseSchema or MustParseSchema. type SchemaOpt func(*Schema) -// NoCommentsAsDescriptions disables the parsing of comments as descriptions -func NoCommentsAsDescriptions() SchemaOpt { +// UseStringDescriptions disables the parsing of comments as descriptions +func UseStringDescriptions() SchemaOpt { return func(s *Schema) { - s.noCommentsAsDescriptions = true + s.useStringDescriptions = true } } diff --git a/internal/common/lexer.go b/internal/common/lexer.go index e5d40aae..6b6028b5 100644 --- a/internal/common/lexer.go +++ b/internal/common/lexer.go @@ -12,10 +12,10 @@ import ( type syntaxError string type Lexer struct { - sc *scanner.Scanner - next rune - descComment string - noCommentsAsDescriptions bool + sc *scanner.Scanner + next rune + descComment string + useStringDescriptions bool } type Ident struct { @@ -23,13 +23,13 @@ type Ident struct { Loc errors.Location } -func NewLexer(s string, noCommentsAsDescriptions bool) *Lexer { +func NewLexer(s string, useStringDescriptions bool) *Lexer { sc := &scanner.Scanner{ Mode: scanner.ScanIdents | scanner.ScanInts | scanner.ScanFloats | scanner.ScanStrings, } sc.Init(strings.NewReader(s)) - return &Lexer{sc: sc, noCommentsAsDescriptions: noCommentsAsDescriptions} + return &Lexer{sc: sc, useStringDescriptions: useStringDescriptions} } func (l *Lexer) CatchSyntaxError(f func()) (errRes *errors.QueryError) { @@ -56,9 +56,9 @@ func (l *Lexer) Peek() rune { // // Consumed comment characters will build the description for the next type or field encountered. // The description is available from `DescComment()`, and will be reset every time `ConsumeWhitespace()` is -// executed unless l.noCommentsAsDescriptions is set. +// executed unless l.useStringDescriptions is set. func (l *Lexer) ConsumeWhitespace() { - if !l.noCommentsAsDescriptions { + if !l.useStringDescriptions { l.descComment = "" } for { @@ -147,7 +147,7 @@ func (l *Lexer) ConsumeToken(expected rune) { } func (l *Lexer) DescComment() string { - if l.noCommentsAsDescriptions { + if l.useStringDescriptions { if l.consumeDescription() { l.ConsumeWhitespace() } @@ -216,7 +216,7 @@ func (l *Lexer) consumeComment() { l.sc.Next() } - if l.descComment != "" && !l.noCommentsAsDescriptions { + if l.descComment != "" && !l.useStringDescriptions { // TODO: use a bytes.Buffer or strings.Builder instead of this. l.descComment += "\n" } @@ -227,7 +227,7 @@ func (l *Lexer) consumeComment() { break } - if !l.noCommentsAsDescriptions { + if !l.useStringDescriptions { // TODO: use a bytes.Buffer or strings.Build instead of this. l.descComment += string(next) } diff --git a/internal/common/lexer_test.go b/internal/common/lexer_test.go index fe1e12ab..9d8e3fa0 100644 --- a/internal/common/lexer_test.go +++ b/internal/common/lexer_test.go @@ -7,11 +7,11 @@ import ( ) type consumeTestCase struct { - description string - definition string - expected string // expected description - failureExpected bool - noCommentsAsDescriptions bool + description string + definition string + expected string // expected description + failureExpected bool + useStringDescriptions bool } // Note that these tests stop as soon as they parse the comments, so even though the rest of the file will fail to parse sometimes, the tests still pass @@ -26,8 +26,8 @@ var consumeTests = []consumeTestCase{{ type Hello { world: String! }`, - expected: "Comment line 1\nComment line 2\nCommas are insignificant", - noCommentsAsDescriptions: false, + expected: "Comment line 1\nComment line 2\nCommas are insignificant", + useStringDescriptions: false, }, { description: "simple string descriptions allowed in old mode", definition: ` @@ -39,8 +39,8 @@ type Hello { type Hello { world: String! }`, - expected: "New style comments", - noCommentsAsDescriptions: true, + expected: "New style comments", + useStringDescriptions: true, }, { description: "triple quote descriptions allowed in old mode", definition: ` @@ -54,14 +54,14 @@ New style comments type Hello { world: String! }`, - expected: "New style comments", - noCommentsAsDescriptions: true, + expected: "New style comments", + useStringDescriptions: true, }} func TestConsume(t *testing.T) { for _, test := range consumeTests { t.Run(test.description, func(t *testing.T) { - lex := common.NewLexer(test.definition, test.noCommentsAsDescriptions) + lex := common.NewLexer(test.definition, test.useStringDescriptions) err := lex.CatchSyntaxError(func() { lex.ConsumeWhitespace() }) if test.failureExpected { diff --git a/internal/schema/schema.go b/internal/schema/schema.go index a8f8c7eb..569b26b2 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -246,8 +246,8 @@ func New() *Schema { } // Parse the schema string. -func (s *Schema) Parse(schemaString string, noCommentsAsDescriptions bool) error { - l := common.NewLexer(schemaString, noCommentsAsDescriptions) +func (s *Schema) Parse(schemaString string, useStringDescriptions bool) error { + l := common.NewLexer(schemaString, useStringDescriptions) err := l.CatchSyntaxError(func() { parseSchema(s, l) }) if err != nil { From f366350e0741fdbfd5e7df15a719c3eeb71b99e1 Mon Sep 17 00:00:00 2001 From: rick olson Date: Mon, 15 Oct 2018 18:15:36 -0600 Subject: [PATCH 24/69] cleanup benchmark for 'go vet' --- enum_test.go | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/enum_test.go b/enum_test.go index 3ad8f4e9..43544029 100644 --- a/enum_test.go +++ b/enum_test.go @@ -11,19 +11,29 @@ type benchStringEnum string func BenchmarkEnumStringStringer(b *testing.B) { v := reflect.ValueOf(benchStringEnum("TEST")) + var out string for n := 0; n < b.N; n++ { - if s, ok := v.Interface().(fmt.Stringer); ok { - s.String() + if _, ok := v.Interface().(fmt.Stringer); ok { + b.Error("this should not need fmt.Stringer") } else { - v.String() + out = v.String() } } + + if out != "TEST" { + b.Errorf("unexpected output: %q", out) + } } func BenchmarkEnumStringFmt(b *testing.B) { v := reflect.ValueOf(benchStringEnum("TEST")) + var out string for n := 0; n < b.N; n++ { - fmt.Sprintf("%s", v) + out = fmt.Sprintf("%s", v) + } + + if out != "TEST" { + b.Errorf("unexpected output: %q", out) } } @@ -35,18 +45,28 @@ func (i benchIntEnum) String() string { func BenchmarkEnumIntStringer(b *testing.B) { v := reflect.ValueOf(benchIntEnum(1)) + var out string for n := 0; n < b.N; n++ { if s, ok := v.Interface().(fmt.Stringer); ok { - s.String() + out = s.String() } else { - v.String() + b.Error("this should use fmt.Stringer") } } + + if out != "1" { + b.Errorf("unexpected output: %q", out) + } } func BenchmarkEnumIntFmt(b *testing.B) { v := reflect.ValueOf(benchIntEnum(1)) + var out string for n := 0; n < b.N; n++ { - fmt.Sprintf("%s", v) + out = fmt.Sprintf("%s", v) + } + + if out != "1" { + b.Errorf("unexpected output: %q", out) } } From 5a22de26d07d1bcb9f27e3d9ffa3c4771b4099f4 Mon Sep 17 00:00:00 2001 From: rick olson Date: Mon, 15 Oct 2018 18:20:43 -0600 Subject: [PATCH 25/69] remove enum benchmark This benchmark was used to justify a tiny change in #218. Now that we all know the joys of fmt.Stringer, I don't see any reason to keep this file around, especially in the root package. --- enum_test.go | 72 ---------------------------------------------------- 1 file changed, 72 deletions(-) delete mode 100644 enum_test.go diff --git a/enum_test.go b/enum_test.go deleted file mode 100644 index 43544029..00000000 --- a/enum_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package graphql_test - -import ( - "fmt" - "reflect" - "strconv" - "testing" -) - -type benchStringEnum string - -func BenchmarkEnumStringStringer(b *testing.B) { - v := reflect.ValueOf(benchStringEnum("TEST")) - var out string - for n := 0; n < b.N; n++ { - if _, ok := v.Interface().(fmt.Stringer); ok { - b.Error("this should not need fmt.Stringer") - } else { - out = v.String() - } - } - - if out != "TEST" { - b.Errorf("unexpected output: %q", out) - } -} - -func BenchmarkEnumStringFmt(b *testing.B) { - v := reflect.ValueOf(benchStringEnum("TEST")) - var out string - for n := 0; n < b.N; n++ { - out = fmt.Sprintf("%s", v) - } - - if out != "TEST" { - b.Errorf("unexpected output: %q", out) - } -} - -type benchIntEnum int - -func (i benchIntEnum) String() string { - return strconv.Itoa(int(i)) -} - -func BenchmarkEnumIntStringer(b *testing.B) { - v := reflect.ValueOf(benchIntEnum(1)) - var out string - for n := 0; n < b.N; n++ { - if s, ok := v.Interface().(fmt.Stringer); ok { - out = s.String() - } else { - b.Error("this should use fmt.Stringer") - } - } - - if out != "1" { - b.Errorf("unexpected output: %q", out) - } -} - -func BenchmarkEnumIntFmt(b *testing.B) { - v := reflect.ValueOf(benchIntEnum(1)) - var out string - for n := 0; n < b.N; n++ { - out = fmt.Sprintf("%s", v) - } - - if out != "1" { - b.Errorf("unexpected output: %q", out) - } -} From 62c5401acea6852dbfda63863fe8bf13ab5a00cf Mon Sep 17 00:00:00 2001 From: Ivan Date: Tue, 16 Oct 2018 13:39:42 +0700 Subject: [PATCH 26/69] Add extensions in QueryError --- errors/errors.go | 11 ++++----- graphql_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/errors/errors.go b/errors/errors.go index fdfa6202..8ffe818e 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -5,11 +5,12 @@ import ( ) type QueryError struct { - Message string `json:"message"` - Locations []Location `json:"locations,omitempty"` - Path []interface{} `json:"path,omitempty"` - Rule string `json:"-"` - ResolverError error `json:"-"` + Message string `json:"message"` + Locations []Location `json:"locations,omitempty"` + Path []interface{} `json:"path,omitempty"` + Rule string `json:"-"` + ResolverError error `json:"-"` + Extensions map[string]interface{} `json:"extensions,omitempty"` } type Location struct { diff --git a/graphql_test.go b/graphql_test.go index 4de202a6..3dfc0c6d 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -3,6 +3,7 @@ package graphql_test import ( "context" "errors" + "fmt" "testing" "time" @@ -65,6 +66,24 @@ func (r *timeResolver) AddHour(args struct{ Time graphql.Time }) graphql.Time { var starwarsSchema = graphql.MustParseSchema(starwars.Schema, &starwars.Resolver{}) +type findDroidResolver struct{} + +type withExtensionError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (e withExtensionError) Error() string { + return fmt.Sprintf("Error [%s] %s", e.Code, e.Message) +} + +func (r *findDroidResolver) FindDroid(ctx context.Context) (string, error) { + return "", withExtensionError{ + Code: "NotFound", + Message: "This is not the droid you are looking for", + } +} + func TestHelloWorld(t *testing.T) { gqltesting.RunTests(t, []*gqltesting.Test{ { @@ -304,6 +323,45 @@ func TestNilInterface(t *testing.T) { }) } +func TestErrorWithExtension(t *testing.T) { + err := withExtensionError{ + Code: "NotFound", + Message: "This is not the droid you are looking for", + } + + gqltesting.RunTests(t, []*gqltesting.Test{ + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + FindDroid: String! + } + `, &findDroidResolver{}), + Query: ` + { + FindDroid + } + `, + ExpectedResult: ` + { + "FindDroid": null + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + &gqlerrors.QueryError{ + Message: err.Error(), + Path: []interface{}{"FindDroid"}, + ResolverError: err, + Extensions: nil, + }, + }, + }, + }) +} + func TestArguments(t *testing.T) { gqltesting.RunTests(t, []*gqltesting.Test{ { From ecd5bd1f4eba03623cac507da4e3df760580d289 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 17 Oct 2018 11:00:30 +0700 Subject: [PATCH 27/69] Support for custom interface that has Extensions() method --- graphql_test.go | 68 ++++++++++++++++++++++++++++++++++++++----- internal/exec/exec.go | 7 +++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/graphql_test.go b/graphql_test.go index 3dfc0c6d..835b1540 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -66,24 +66,42 @@ func (r *timeResolver) AddHour(args struct{ Time graphql.Time }) graphql.Time { var starwarsSchema = graphql.MustParseSchema(starwars.Schema, &starwars.Resolver{}) -type findDroidResolver struct{} +type ResolverError interface { + error + Extensions() map[string]interface{} +} -type withExtensionError struct { +type resolverNotFoundError struct { Code string `json:"code"` Message string `json:"message"` } -func (e withExtensionError) Error() string { - return fmt.Sprintf("Error [%s] %s", e.Code, e.Message) +func (e resolverNotFoundError) Error() string { + return fmt.Sprintf("Error [%s]: %s", e.Code, e.Message) +} + +func (e resolverNotFoundError) Extensions() map[string]interface{} { + return map[string]interface{}{ + "code": e.Code, + "message": e.Message, + } } +type findDroidResolver struct{} + func (r *findDroidResolver) FindDroid(ctx context.Context) (string, error) { - return "", withExtensionError{ + return "", resolverNotFoundError{ Code: "NotFound", Message: "This is not the droid you are looking for", } } +type discussPlanResolver struct{} + +func (r *discussPlanResolver) DismissVader(ctx context.Context) (string, error) { + return "", errors.New("I find your lack of faith disturbing") +} + func TestHelloWorld(t *testing.T) { gqltesting.RunTests(t, []*gqltesting.Test{ { @@ -323,8 +341,8 @@ func TestNilInterface(t *testing.T) { }) } -func TestErrorWithExtension(t *testing.T) { - err := withExtensionError{ +func TestErrorWithExtensions(t *testing.T) { + err := resolverNotFoundError{ Code: "NotFound", Message: "This is not the droid you are looking for", } @@ -355,6 +373,42 @@ func TestErrorWithExtension(t *testing.T) { Message: err.Error(), Path: []interface{}{"FindDroid"}, ResolverError: err, + Extensions: map[string]interface{}{"code": err.Code, "message": err.Message}, + }, + }, + }, + }) +} + +func TestErrorWithNoExtensions(t *testing.T) { + err := errors.New("I find your lack of faith disturbing") + + gqltesting.RunTests(t, []*gqltesting.Test{ + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + DismissVader: String! + } + `, &discussPlanResolver{}), + Query: ` + { + DismissVader + } + `, + ExpectedResult: ` + { + "DismissVader": null + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + &gqlerrors.QueryError{ + Message: err.Error(), + Path: []interface{}{"DismissVader"}, + ResolverError: err, Extensions: nil, }, }, diff --git a/internal/exec/exec.go b/internal/exec/exec.go index e6cca744..01e2092f 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -31,6 +31,10 @@ func (r *Request) handlePanic(ctx context.Context) { } } +type extensionser interface { + Extensions() map[string]interface{} +} + func makePanicError(value interface{}) *errors.QueryError { return errors.Errorf("graphql: panic occurred: %v", value) } @@ -187,6 +191,9 @@ func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *p err := errors.Errorf("%s", resolverErr) err.Path = path.toSlice() err.ResolverError = resolverErr + if ex, ok := callOut[1].Interface().(extensionser); ok { + err.Extensions = ex.Extensions() + } return err } return nil From c3e1fe54e8eab900916f0b1e67bf5309c64ea555 Mon Sep 17 00:00:00 2001 From: Harmen Date: Thu, 27 Sep 2018 18:49:28 +0200 Subject: [PATCH 28/69] simpler error comparisons Before you would get the unhelpful message: testing.go:82: unexpected number of errors: got 1, want 0 but now you get: testing.go:80: unexpected error: got [graphql: Cannot query field "timestamp" on type "SMSList". (line 1, column 40)], want [] --- gqltesting/testing.go | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/gqltesting/testing.go b/gqltesting/testing.go index 14dc9f0b..85ab4c35 100644 --- a/gqltesting/testing.go +++ b/gqltesting/testing.go @@ -74,27 +74,8 @@ func formatJSON(data []byte) ([]byte, error) { return formatted, nil } -func checkErrors(t *testing.T, expected, actual []*errors.QueryError) { - expectedCount, actualCount := len(expected), len(actual) - - if expectedCount != actualCount { - t.Fatalf("unexpected number of errors: got %d, want %d", actualCount, expectedCount) - } - - if expectedCount > 0 { - for i, want := range expected { - got := actual[i] - - if !reflect.DeepEqual(got, want) { - t.Fatalf("unexpected error: got %+v, want %+v", got, want) - } - } - - // Return because we're done checking. - return - } - - for _, err := range actual { - t.Errorf("unexpected error: '%s'", err) +func checkErrors(t *testing.T, want, got []*errors.QueryError) { + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected error: got %+v, want %+v", got, want) } } From 2b2d3e558277b929779b7583054759e9579c1e11 Mon Sep 17 00:00:00 2001 From: Pavel Nikolov Date: Tue, 23 Oct 2018 02:30:02 +1100 Subject: [PATCH 29/69] Add subscriptions feature to the README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ef4b4639..8d68d2c7 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ safe for production use. - resolvers are matched to the schema based on method sets (can resolve a GraphQL schema with a Go interface or Go struct). - handles panics in resolvers - parallel execution of resolvers +- subscriptions + - [sample WS transport](https://github.com/graph-gophers/graphql-transport-ws) ## Roadmap From 6c6e29f81d04537e250ac5dd9b7b6ae8c7abc333 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Mon, 22 Oct 2018 21:58:12 +0000 Subject: [PATCH 30/69] remove impossible cases, improve tests --- graphql.go | 5 ++++- internal/common/lexer.go | 12 ++---------- internal/common/lexer_test.go | 19 ++++++++++++++++--- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/graphql.go b/graphql.go index f102b097..a4f9b73d 100644 --- a/graphql.go +++ b/graphql.go @@ -74,7 +74,10 @@ type Schema struct { // SchemaOpt is an option to pass to ParseSchema or MustParseSchema. type SchemaOpt func(*Schema) -// UseStringDescriptions disables the parsing of comments as descriptions +// UseStringDescriptions enables the usage of double quoted and triple quoted +// strings as descriptions as per the June 2018 spec +// https://facebook.github.io/graphql/June2018/. When this is not enabled, +// comments are parsed as descriptions instead. func UseStringDescriptions() SchemaOpt { return func(s *Schema) { s.useStringDescriptions = true diff --git a/internal/common/lexer.go b/internal/common/lexer.go index 6b6028b5..8b3176c9 100644 --- a/internal/common/lexer.go +++ b/internal/common/lexer.go @@ -171,12 +171,8 @@ func (l *Lexer) consumeTripleQuoteComment() { panic("consumeTripleQuoteComment used in wrong context: no third quote?") } - if l.descComment != "" { - l.descComment += "\n" - } - - comment := "" - numQuotes := 0 + var comment string + var numQuotes int for { l.next = l.sc.Next() if l.next == '"' { @@ -193,10 +189,6 @@ func (l *Lexer) consumeTripleQuoteComment() { } func (l *Lexer) consumeStringComment(str string) { - if l.descComment != "" { - l.descComment += "\n" - } - value, err := strconv.Unquote(str) if err != nil { panic(err) diff --git a/internal/common/lexer_test.go b/internal/common/lexer_test.go index 9d8e3fa0..40e967ed 100644 --- a/internal/common/lexer_test.go +++ b/internal/common/lexer_test.go @@ -29,7 +29,7 @@ type Hello { expected: "Comment line 1\nComment line 2\nCommas are insignificant", useStringDescriptions: false, }, { - description: "simple string descriptions allowed in old mode", + description: "simple string descriptions allowed in new mode", definition: ` # Comment line 1 @@ -42,7 +42,19 @@ type Hello { expected: "New style comments", useStringDescriptions: true, }, { - description: "triple quote descriptions allowed in old mode", + description: "comment after description works", + definition: ` + +# Comment line 1 +#Comment line 2 +,,,,,, # Commas are insignificant +type Hello { + world: String! +}`, + expected: "", + useStringDescriptions: true, +}, { + description: "triple quote descriptions allowed in new mode", definition: ` # Comment line 1 @@ -50,11 +62,12 @@ type Hello { ,,,,,, # Commas are insignificant """ New style comments +Another line """ type Hello { world: String! }`, - expected: "New style comments", + expected: "New style comments\nAnother line", useStringDescriptions: true, }} From 86130ac51668b74fefdb5fca5cf78a8865a26845 Mon Sep 17 00:00:00 2001 From: Salman Ahmad Date: Tue, 30 Oct 2018 09:18:11 -0400 Subject: [PATCH 31/69] Use struct fields as resolvers instead of methods (#28) --- .gitignore | 3 + README.md | 12 +- example/social/README.md | 9 ++ example/social/server/server.go | 63 ++++++++ example/social/social.go | 206 +++++++++++++++++++++++++ graphql.go | 10 +- internal/exec/exec.go | 43 ++++-- internal/exec/resolvable/resolvable.go | 175 +++++++++++++-------- internal/schema/schema.go | 2 + 9 files changed, 442 insertions(+), 81 deletions(-) create mode 100644 example/social/README.md create mode 100644 example/social/server/server.go create mode 100644 example/social/social.go diff --git a/.gitignore b/.gitignore index 7b3bcd13..cf07dd5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /internal/validation/testdata/graphql-js /internal/validation/testdata/node_modules /vendor +.DS_Store +.idea/ +.vscode/ diff --git a/README.md b/README.md index 8d68d2c7..a0b6d58a 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,17 @@ $ curl -XPOST -d '{"query": "{ hello }"}' localhost:8080/query ### Resolvers -A resolver must have one method for each field of the GraphQL type it resolves. The method name has to be [exported](https://golang.org/ref/spec#Exported_identifiers) and match the field's name in a non-case-sensitive way. +A resolver must have one method or field for each field of the GraphQL type it resolves. The method or field name has to be [exported](https://golang.org/ref/spec#Exported_identifiers) and match the schema's field's name in a non-case-sensitive way. +You can use struct fields as resolvers by using `SchemaOpt: UseFieldResolvers()`. For example, +``` +opts := []graphql.SchemaOpt{graphql.UseFieldResolvers()} +schema := graphql.MustParseSchema(s, &query{}, opts...) +``` + +When using `UseFieldResolvers`, a field will be used *only* when: +- there is no method +- it does not implement an interface +- it does not have arguments The method has up to two arguments: diff --git a/example/social/README.md b/example/social/README.md new file mode 100644 index 00000000..5ab316fd --- /dev/null +++ b/example/social/README.md @@ -0,0 +1,9 @@ +### Social App + +A simple example of how to use struct fields as resolvers instead of methods. + +To run this server + +`go run ./example/field-resolvers/server/server.go` + +and go to localhost:9011 to interact \ No newline at end of file diff --git a/example/social/server/server.go b/example/social/server/server.go new file mode 100644 index 00000000..21b6f384 --- /dev/null +++ b/example/social/server/server.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "net/http" + + "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/example/social" + "github.com/graph-gophers/graphql-go/relay" +) + +func main() { + + opts := []graphql.SchemaOpt{graphql.UseFieldResolvers(), graphql.MaxParallelism(20)} + schema := graphql.MustParseSchema(social.Schema, &social.Resolver{}, opts...) + + http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(page) + })) + + http.Handle("/query", &relay.Handler{Schema: schema}) + + log.Fatal(http.ListenAndServe(":9011", nil)) +} + +var page = []byte(` + + + + + + + + + + + +
Loading...
+ + + +`) diff --git a/example/social/social.go b/example/social/social.go new file mode 100644 index 00000000..83f26ad6 --- /dev/null +++ b/example/social/social.go @@ -0,0 +1,206 @@ +package social + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/graph-gophers/graphql-go" +) + +const Schema = ` + schema { + query: Query + } + + type Query { + admin(id: ID!, role: Role = ADMIN): Admin! + user(id: ID!): User! + search(text: String!): [SearchResult]! + } + + interface Admin { + id: ID! + name: String! + role: Role! + } + + scalar Time + + type User implements Admin { + id: ID! + name: String! + email: String! + role: Role! + phone: String! + address: [String!] + friends(page: Pagination): [User] + createdAt: Time! + } + + input Pagination { + first: Int + last: Int + } + + enum Role { + ADMIN + USER + } + + union SearchResult = User +` + +type page struct { + First *float64 + Last *float64 +} + +type admin interface { + ID() graphql.ID + Name() string + Role() string +} + +type searchResult struct { + result interface{} +} + +func (r *searchResult) ToUser() (*user, bool) { + res, ok := r.result.(*user) + return res, ok +} + +type user struct { + IDField string + NameField string + RoleField string + Email string + Phone string + Address *[]string + Friends *[]*user + CreatedAt graphql.Time +} + +func (u user) ID() graphql.ID { + return graphql.ID(u.IDField) +} + +func (u user) Name() string { + return u.NameField +} + +func (u user) Role() string { + return u.RoleField +} + +func (u user) FriendsResolver(args struct{ Page *page }) (*[]*user, error) { + var from int + numFriends := len(*u.Friends) + to := numFriends + + if args.Page != nil { + if args.Page.First != nil { + from = int(*args.Page.First) + if from > numFriends { + return nil, errors.New("not enough users") + } + } + if args.Page.Last != nil { + to = int(*args.Page.Last) + if to == 0 || to > numFriends { + to = numFriends + } + } + } + + friends := (*u.Friends)[from:to] + + return &friends, nil +} + +var users = []*user{ + { + IDField: "0x01", + NameField: "Albus Dumbledore", + RoleField: "ADMIN", + Email: "Albus@hogwarts.com", + Phone: "000-000-0000", + Address: &[]string{"Office @ Hogwarts", "where Horcruxes are"}, + CreatedAt: graphql.Time{Time: time.Now()}, + }, + { + IDField: "0x02", + NameField: "Harry Potter", + RoleField: "USER", + Email: "harry@hogwarts.com", + Phone: "000-000-0001", + Address: &[]string{"123 dorm room @ Hogwarts", "456 random place"}, + CreatedAt: graphql.Time{Time: time.Now()}, + }, + { + IDField: "0x03", + NameField: "Hermione Granger", + RoleField: "USER", + Email: "hermione@hogwarts.com", + Phone: "000-000-0011", + Address: &[]string{"233 dorm room @ Hogwarts", "786 @ random place"}, + CreatedAt: graphql.Time{Time: time.Now()}, + }, + { + IDField: "0x04", + NameField: "Ronald Weasley", + RoleField: "USER", + Email: "ronald@hogwarts.com", + Phone: "000-000-0111", + Address: &[]string{"411 dorm room @ Hogwarts", "981 @ random place"}, + CreatedAt: graphql.Time{Time: time.Now()}, + }, +} + +var usersMap = make(map[string]*user) + +func init() { + users[0].Friends = &[]*user{users[1]} + users[1].Friends = &[]*user{users[0], users[2], users[3]} + users[2].Friends = &[]*user{users[1], users[3]} + users[3].Friends = &[]*user{users[1], users[2]} + for _, usr := range users { + usersMap[usr.IDField] = usr + } +} + +type Resolver struct{} + +func (r *Resolver) Admin(ctx context.Context, args struct { + Id string + Role string +}) (admin, error) { + if usr, ok := usersMap[args.Id]; ok { + if usr.RoleField == args.Role { + return *usr, nil + } + } + err := fmt.Errorf("user with id=%s and role=%s does not exist", args.Id, args.Role) + return user{}, err +} + +func (r *Resolver) User(ctx context.Context, args struct{ Id string }) (user, error) { + if usr, ok := usersMap[args.Id]; ok { + return *usr, nil + } + err := fmt.Errorf("user with id=%s does not exist", args.Id) + return user{}, err +} + +func (r *Resolver) Search(ctx context.Context, args struct{ Text string }) ([]*searchResult, error) { + var result []*searchResult + for _, usr := range users { + if strings.Contains(usr.NameField, args.Text) { + result = append(result, &searchResult{usr}) + } + } + return result, nil +} diff --git a/graphql.go b/graphql.go index 35768a47..12b17dfc 100644 --- a/graphql.go +++ b/graphql.go @@ -2,9 +2,8 @@ package graphql import ( "context" - "fmt" - "encoding/json" + "fmt" "github.com/graph-gophers/graphql-go/errors" "github.com/graph-gophers/graphql-go/internal/common" @@ -84,6 +83,13 @@ func UseStringDescriptions() SchemaOpt { } } +// Specifies whether to use struct field resolvers +func UseFieldResolvers() SchemaOpt { + return func(s *Schema) { + s.schema.UseFieldResolvers = true + } +} + // MaxDepth specifies the maximum field nesting depth in a query. The default is 0 which disables max depth checking. func MaxDepth(n int) SchemaOpt { return func(s *Schema) { diff --git a/internal/exec/exec.go b/internal/exec/exec.go index c326fc95..e878888f 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -178,24 +178,33 @@ func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *p return errors.Errorf("%s", err) // don't execute any more resolvers if context got cancelled } - var in []reflect.Value - if f.field.HasContext { - in = append(in, reflect.ValueOf(traceCtx)) - } - if f.field.ArgsPacker != nil { - in = append(in, f.field.PackedArgs) - } - callOut := f.resolver.Method(f.field.MethodIndex).Call(in) - result = callOut[0] - if f.field.HasError && !callOut[1].IsNil() { - resolverErr := callOut[1].Interface().(error) - err := errors.Errorf("%s", resolverErr) - err.Path = path.toSlice() - err.ResolverError = resolverErr - if ex, ok := callOut[1].Interface().(extensionser); ok { - err.Extensions = ex.Extensions() + res := f.resolver + if f.field.UseMethodResolver() { + var in []reflect.Value + if f.field.HasContext { + in = append(in, reflect.ValueOf(traceCtx)) + } + if f.field.ArgsPacker != nil { + in = append(in, f.field.PackedArgs) + } + callOut := res.Method(f.field.MethodIndex).Call(in) + result = callOut[0] + if f.field.HasError && !callOut[1].IsNil() { + resolverErr := callOut[1].Interface().(error) + err := errors.Errorf("%s", resolverErr) + err.Path = path.toSlice() + err.ResolverError = resolverErr + if ex, ok := callOut[1].Interface().(extensionser); ok { + err.Extensions = ex.Extensions() + } + return err + } + } else { + // TODO extract out unwrapping ptr logic to a common place + if res.Kind() == reflect.Ptr { + res = res.Elem() } - return err + result = res.Field(f.field.FieldIndex) } return nil }() diff --git a/internal/exec/resolvable/resolvable.go b/internal/exec/resolvable/resolvable.go index c4802520..27809230 100644 --- a/internal/exec/resolvable/resolvable.go +++ b/internal/exec/resolvable/resolvable.go @@ -33,6 +33,7 @@ type Field struct { schema.Field TypeName string MethodIndex int + FieldIndex int HasContext bool HasError bool ArgsPacker *packer.StructPacker @@ -40,6 +41,10 @@ type Field struct { TraceLabel string } +func (f *Field) UseMethodResolver() bool { + return f.FieldIndex == -1 +} + type TypeAssertion struct { MethodIndex int TypeExec Resolvable @@ -189,13 +194,13 @@ func makeScalarExec(t *schema.Scalar, resolverType reflect.Type) (Resolvable, er implementsType := false switch r := reflect.New(resolverType).Interface().(type) { case *int32: - implementsType = (t.Name == "Int") + implementsType = t.Name == "Int" case *float64: - implementsType = (t.Name == "Float") + implementsType = t.Name == "Float" case *string: - implementsType = (t.Name == "String") + implementsType = t.Name == "String" case *bool: - implementsType = (t.Name == "Boolean") + implementsType = t.Name == "Boolean" case packer.Unmarshaler: implementsType = r.ImplementsGraphQLType(t.Name) } @@ -205,7 +210,8 @@ func makeScalarExec(t *schema.Scalar, resolverType reflect.Type) (Resolvable, er return &Scalar{}, nil } -func (b *execBuilder) makeObjectExec(typeName string, fields schema.FieldList, possibleTypes []*schema.Object, nonNull bool, resolverType reflect.Type) (*Object, error) { +func (b *execBuilder) makeObjectExec(typeName string, fields schema.FieldList, possibleTypes []*schema.Object, + nonNull bool, resolverType reflect.Type) (*Object, error) { if !nonNull { if resolverType.Kind() != reflect.Ptr && resolverType.Kind() != reflect.Interface { return nil, fmt.Errorf("%s is not a pointer or interface", resolverType) @@ -215,9 +221,14 @@ func (b *execBuilder) makeObjectExec(typeName string, fields schema.FieldList, p methodHasReceiver := resolverType.Kind() != reflect.Interface Fields := make(map[string]*Field) + rt := unwrapPtr(resolverType) for _, f := range fields { + fieldIndex := -1 methodIndex := findMethod(resolverType, f.Name) - if methodIndex == -1 { + if b.schema.UseFieldResolvers && methodIndex == -1 { + fieldIndex = findField(rt, f.Name) + } + if methodIndex == -1 && fieldIndex == -1 { hint := "" if findMethod(reflect.PtrTo(resolverType), f.Name) != -1 { hint = " (hint: the method exists on the pointer type)" @@ -225,30 +236,41 @@ func (b *execBuilder) makeObjectExec(typeName string, fields schema.FieldList, p return nil, fmt.Errorf("%s does not resolve %q: missing method for field %q%s", resolverType, typeName, f.Name, hint) } - m := resolverType.Method(methodIndex) - fe, err := b.makeFieldExec(typeName, f, m, methodIndex, methodHasReceiver) + var m reflect.Method + var sf reflect.StructField + if methodIndex != -1 { + m = resolverType.Method(methodIndex) + } else { + sf = rt.Field(fieldIndex) + } + fe, err := b.makeFieldExec(typeName, f, m, sf, methodIndex, fieldIndex, methodHasReceiver) if err != nil { return nil, fmt.Errorf("%s\n\treturned by (%s).%s", err, resolverType, m.Name) } Fields[f.Name] = fe } + // Check type assertions when + // 1) using method resolvers + // 2) Or resolver is not an interface type typeAssertions := make(map[string]*TypeAssertion) - for _, impl := range possibleTypes { - methodIndex := findMethod(resolverType, "To"+impl.Name) - if methodIndex == -1 { - return nil, fmt.Errorf("%s does not resolve %q: missing method %q to convert to %q", resolverType, typeName, "To"+impl.Name, impl.Name) - } - if resolverType.Method(methodIndex).Type.NumOut() != 2 { - return nil, fmt.Errorf("%s does not resolve %q: method %q should return a value and a bool indicating success", resolverType, typeName, "To"+impl.Name) - } - a := &TypeAssertion{ - MethodIndex: methodIndex, - } - if err := b.assignExec(&a.TypeExec, impl, resolverType.Method(methodIndex).Type.Out(0)); err != nil { - return nil, err + if !b.schema.UseFieldResolvers || resolverType.Kind() != reflect.Interface { + for _, impl := range possibleTypes { + methodIndex := findMethod(resolverType, "To"+impl.Name) + if methodIndex == -1 { + return nil, fmt.Errorf("%s does not resolve %q: missing method %q to convert to %q", resolverType, typeName, "To"+impl.Name, impl.Name) + } + if resolverType.Method(methodIndex).Type.NumOut() != 2 { + return nil, fmt.Errorf("%s does not resolve %q: method %q should return a value and a bool indicating success", resolverType, typeName, "To"+impl.Name) + } + a := &TypeAssertion{ + MethodIndex: methodIndex, + } + if err := b.assignExec(&a.TypeExec, impl, resolverType.Method(methodIndex).Type.Out(0)); err != nil { + return nil, err + } + typeAssertions[impl.Name] = a } - typeAssertions[impl.Name] = a } return &Object{ @@ -261,50 +283,58 @@ func (b *execBuilder) makeObjectExec(typeName string, fields schema.FieldList, p var contextType = reflect.TypeOf((*context.Context)(nil)).Elem() var errorType = reflect.TypeOf((*error)(nil)).Elem() -func (b *execBuilder) makeFieldExec(typeName string, f *schema.Field, m reflect.Method, methodIndex int, methodHasReceiver bool) (*Field, error) { - in := make([]reflect.Type, m.Type.NumIn()) - for i := range in { - in[i] = m.Type.In(i) - } - if methodHasReceiver { - in = in[1:] // first parameter is receiver - } - - hasContext := len(in) > 0 && in[0] == contextType - if hasContext { - in = in[1:] - } +func (b *execBuilder) makeFieldExec(typeName string, f *schema.Field, m reflect.Method, sf reflect.StructField, + methodIndex, fieldIndex int, methodHasReceiver bool) (*Field, error) { var argsPacker *packer.StructPacker - if len(f.Args) > 0 { - if len(in) == 0 { - return nil, fmt.Errorf("must have parameter for field arguments") + var hasError bool + var hasContext bool + + // Validate resolver method only when there is one + if methodIndex != -1 { + in := make([]reflect.Type, m.Type.NumIn()) + for i := range in { + in[i] = m.Type.In(i) } - var err error - argsPacker, err = b.packerBuilder.MakeStructPacker(f.Args, in[0]) - if err != nil { - return nil, err + if methodHasReceiver { + in = in[1:] // first parameter is receiver } - in = in[1:] - } - if len(in) > 0 { - return nil, fmt.Errorf("too many parameters") - } + hasContext = len(in) > 0 && in[0] == contextType + if hasContext { + in = in[1:] + } - maxNumOfReturns := 2 - if m.Type.NumOut() < maxNumOfReturns-1 { - return nil, fmt.Errorf("too few return values") - } + if len(f.Args) > 0 { + if len(in) == 0 { + return nil, fmt.Errorf("must have parameter for field arguments") + } + var err error + argsPacker, err = b.packerBuilder.MakeStructPacker(f.Args, in[0]) + if err != nil { + return nil, err + } + in = in[1:] + } - if m.Type.NumOut() > maxNumOfReturns { - return nil, fmt.Errorf("too many return values") - } + if len(in) > 0 { + return nil, fmt.Errorf("too many parameters") + } - hasError := m.Type.NumOut() == maxNumOfReturns - if hasError { - if m.Type.Out(maxNumOfReturns-1) != errorType { - return nil, fmt.Errorf(`must have "error" as its last return value`) + maxNumOfReturns := 2 + if m.Type.NumOut() < maxNumOfReturns-1 { + return nil, fmt.Errorf("too few return values") + } + + if m.Type.NumOut() > maxNumOfReturns { + return nil, fmt.Errorf("too many return values") + } + + hasError = m.Type.NumOut() == maxNumOfReturns + if hasError { + if m.Type.Out(maxNumOfReturns-1) != errorType { + return nil, fmt.Errorf(`must have "error" as its last return value`) + } } } @@ -312,19 +342,26 @@ func (b *execBuilder) makeFieldExec(typeName string, f *schema.Field, m reflect. Field: *f, TypeName: typeName, MethodIndex: methodIndex, + FieldIndex: fieldIndex, HasContext: hasContext, ArgsPacker: argsPacker, HasError: hasError, TraceLabel: fmt.Sprintf("GraphQL field: %s.%s", typeName, f.Name), } - out := m.Type.Out(0) - if typeName == "Subscription" && out.Kind() == reflect.Chan { - out = m.Type.Out(0).Elem() + var out reflect.Type + if methodIndex != -1 { + out = m.Type.Out(0) + if typeName == "Subscription" && out.Kind() == reflect.Chan { + out = m.Type.Out(0).Elem() + } + } else { + out = sf.Type } if err := b.assignExec(&fe.ValueExec, f.Type, out); err != nil { return nil, err } + return fe, nil } @@ -337,6 +374,15 @@ func findMethod(t reflect.Type, name string) int { return -1 } +func findField(t reflect.Type, name string) int { + for i := 0; i < t.NumField(); i++ { + if strings.EqualFold(stripUnderscore(name), stripUnderscore(t.Field(i).Name)) { + return i + } + } + return -1 +} + func unwrapNonNull(t common.Type) (common.Type, bool) { if nn, ok := t.(*common.NonNull); ok { return nn.OfType, true @@ -347,3 +393,10 @@ func unwrapNonNull(t common.Type) (common.Type, bool) { func stripUnderscore(s string) string { return strings.Replace(s, "_", "", -1) } + +func unwrapPtr(t reflect.Type) reflect.Type { + if t.Kind() == reflect.Ptr { + return t.Elem() + } + return t +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 569b26b2..08cc47e3 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -41,6 +41,8 @@ type Schema struct { // http://facebook.github.io/graphql/draft/#sec-Type-System.Directives Directives map[string]*DirectiveDecl + UseFieldResolvers bool + entryPointNames map[string]string objects []*Object unions []*Union From 074fe8753f0ef125981c96383d030f2e72b2e233 Mon Sep 17 00:00:00 2001 From: Matias Anaya Date: Sat, 10 Nov 2018 14:21:58 +1100 Subject: [PATCH 32/69] Fix #286: Support queries/mutations through alternative transports --- subscription_test.go | 8 ++++++-- subscriptions.go | 10 +++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/subscription_test.go b/subscription_test.go index 20a4d618..803ee159 100644 --- a/subscription_test.go +++ b/subscription_test.go @@ -130,7 +130,7 @@ func TestSchemaSubscribe(t *testing.T) { }, }, { - Name: "subscribe_to_query_errors", + Name: "subscribe_to_query_succeeds", Schema: graphql.MustParseSchema(schema, &rootResolver{}), Query: ` query Hello { @@ -139,7 +139,11 @@ func TestSchemaSubscribe(t *testing.T) { `, ExpectedResults: []gqltesting.TestResponse{ { - Errors: []*qerrors.QueryError{qerrors.Errorf("%s: %s", "subscription unavailable for operation of type", "QUERY")}, + Data: json.RawMessage(` + { + "hello": "Hello world!" + } + `), }, }, }, diff --git a/subscriptions.go b/subscriptions.go index 2c1731bd..a78aa765 100644 --- a/subscriptions.go +++ b/subscriptions.go @@ -44,11 +44,6 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{qerrors.Errorf("%s", err)}}) } - // TODO: Move to validation.Validate? - if op.Type != query.Subscription { - return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{qerrors.Errorf("%s: %s", "subscription unavailable for operation of type", op.Type)}}) - } - r := &exec.Request{ Request: selected.Request{ Doc: doc, @@ -68,6 +63,11 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam varTypes[v.Name.Name] = introspection.WrapType(t) } + if op.Type == query.Query || op.Type == query.Mutation { + data, errs := r.Execute(ctx, res, op) + return sendAndReturnClosed(&Response{Data: data, Errors: errs}) + } + responses := r.Subscribe(ctx, res, op) c := make(chan *Response) go func() { From 6fa3ec92f38c9e26f76f5fa8eee9541ef49b72e3 Mon Sep 17 00:00:00 2001 From: Matias Anaya Date: Tue, 13 Nov 2018 15:50:56 +1100 Subject: [PATCH 33/69] Switch to interface{} for Subscribe() return --- gqltesting/subscriptions.go | 2 +- subscriptions.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gqltesting/subscriptions.go b/gqltesting/subscriptions.go index ea25514e..b18ab6a5 100644 --- a/gqltesting/subscriptions.go +++ b/gqltesting/subscriptions.go @@ -57,7 +57,7 @@ func RunSubscribe(t *testing.T, test *TestSubscription) { var results []*graphql.Response for res := range c { - results = append(results, res) + results = append(results, res.(*graphql.Response)) } for i, expected := range test.ExpectedResults { diff --git a/subscriptions.go b/subscriptions.go index a78aa765..fbec253a 100644 --- a/subscriptions.go +++ b/subscriptions.go @@ -19,14 +19,14 @@ import ( // If the context gets cancelled, the response channel will be closed and no // further resolvers will be called. The context error will be returned as soon // as possible (not immediately). -func (s *Schema) Subscribe(ctx context.Context, queryString string, operationName string, variables map[string]interface{}) (<-chan *Response, error) { +func (s *Schema) Subscribe(ctx context.Context, queryString string, operationName string, variables map[string]interface{}) (<-chan interface{}, error) { if s.res == nil { return nil, errors.New("schema created without resolver, can not subscribe") } return s.subscribe(ctx, queryString, operationName, variables, s.res), nil } -func (s *Schema) subscribe(ctx context.Context, queryString string, operationName string, variables map[string]interface{}, res *resolvable.Schema) <-chan *Response { +func (s *Schema) subscribe(ctx context.Context, queryString string, operationName string, variables map[string]interface{}, res *resolvable.Schema) <-chan interface{} { doc, qErr := query.Parse(queryString) if qErr != nil { return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{qErr}}) @@ -69,7 +69,7 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam } responses := r.Subscribe(ctx, res, op) - c := make(chan *Response) + c := make(chan interface{}) go func() { for resp := range responses { c <- &Response{ @@ -83,8 +83,8 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam return c } -func sendAndReturnClosed(resp *Response) chan *Response { - c := make(chan *Response, 1) +func sendAndReturnClosed(resp *Response) chan interface{} { + c := make(chan interface{}, 1) c <- resp close(c) return c From 050454c90fa1eeeedb5c59a7dd147ca70a645645 Mon Sep 17 00:00:00 2001 From: fadi-alkatut <44017676+fadi-alkatut@users.noreply.github.com> Date: Wed, 14 Nov 2018 08:47:50 +1100 Subject: [PATCH 34/69] Update testing.go check for errors before expected response --- gqltesting/testing.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gqltesting/testing.go b/gqltesting/testing.go index 85ab4c35..5f4e634d 100644 --- a/gqltesting/testing.go +++ b/gqltesting/testing.go @@ -43,6 +43,9 @@ func RunTest(t *testing.T, test *Test) { test.Context = context.Background() } result := test.Schema.Exec(test.Context, test.Query, test.OperationName, test.Variables) + + checkErrors(t, test.ExpectedErrors, result.Errors) + // Verify JSON to avoid red herring errors. got, err := formatJSON(result.Data) if err != nil { @@ -53,8 +56,6 @@ func RunTest(t *testing.T, test *Test) { t.Fatalf("want: invalid JSON: %s", err) } - checkErrors(t, test.ExpectedErrors, result.Errors) - if !bytes.Equal(got, want) { t.Logf("got: %s", got) t.Logf("want: %s", want) From 9be23d93ff047f2aead52cc22a667f1056262069 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Wed, 20 Jun 2018 03:26:16 +0000 Subject: [PATCH 35/69] add SubPathHasError util on Request --- internal/exec/selected/selected.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/exec/selected/selected.go b/internal/exec/selected/selected.go index 2a957f55..bd33efa7 100644 --- a/internal/exec/selected/selected.go +++ b/internal/exec/selected/selected.go @@ -28,6 +28,31 @@ func (r *Request) AddError(err *errors.QueryError) { r.Mu.Unlock() } +// pathPrefixMatch checks if `path` starts with `prefix` +func pathPrefixMatch(path []interface{}, prefix []interface{}) bool { + if len(prefix) > len(path) { + return false + } + for i, component := range prefix { + if path[i] != component { + return false + } + } + return true +} + +// SubPathHasError returns true if any path that is a subpath of the given path has added errors to the response +func (r *Request) SubPathHasError(path []interface{}) bool { + r.Mu.Lock() + defer r.Mu.Unlock() + for _, err := range r.Errs { + if pathPrefixMatch(err.Path, path) { + return true + } + } + return false +} + func ApplyOperation(r *Request, s *resolvable.Schema, op *query.Operation) []Selection { var obj *resolvable.Object switch op.Type { From e70b076ae03254b604d8b15ced1aa6b5be0e8131 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Wed, 20 Jun 2018 03:26:37 +0000 Subject: [PATCH 36/69] respect null/non-null fields --- internal/exec/exec.go | 64 +++++++++++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index c326fc95..f5855fb0 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -45,7 +45,7 @@ func (r *Request) Execute(ctx context.Context, s *resolvable.Schema, op *query.O func() { defer r.handlePanic(ctx) sels := selected.ApplyOperation(&r.Request, s, op) - r.execSelections(ctx, sels, nil, s.Resolver, &out, op.Type == query.Mutation) + r.execSelections(ctx, sels, nil, s.Resolver, &out, op.Type == query.Mutation, false) }() if err := ctx.Err(); err != nil { @@ -62,7 +62,7 @@ type fieldToExec struct { out *bytes.Buffer } -func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, serially bool) { +func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, serially bool, isNonNull bool) { async := !serially && selected.HasAsyncSel(sels) var fields []*fieldToExec @@ -80,25 +80,51 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, }(f) } wg.Wait() + } else { + for _, f := range fields { + f.out = new(bytes.Buffer) + execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, true) + } } - out.WriteByte('{') - for i, f := range fields { - if i > 0 { - out.WriteByte(',') + // | nullable field | non-nullable field + // ------------------------------------------------------------------------------- + // non-nullable child has error | print null | print nothing, wait for parent to print null + // no non-nullable child error | print output | print output + + childHasError := false + for _, f := range fields { + if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild && r.SubPathHasError((&pathSegment{path, f.field.Alias}).toSlice()) { + childHasError = true + break } - out.WriteByte('"') - out.WriteString(f.field.Alias) - out.WriteByte('"') - out.WriteByte(':') - if async { + } + + // If the child has no error, we simply write out the results + if !childHasError { + out.WriteByte('{') + for i, f := range fields { + if i > 0 { + out.WriteByte(',') + } + out.WriteByte('"') + out.WriteString(f.field.Alias) + out.WriteByte('"') + out.WriteByte(':') out.Write(f.out.Bytes()) - continue } - f.out = out - execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, false) + out.WriteByte('}') + return } - out.WriteByte('}') + + // We exit early in the non-null case because we want the parent to write out its response instead + // and an error has already been set, indicating to the parent that it should become null + if isNonNull { + return + } + + // If there's an error and the current field is nullable, we write out a null + out.Write([]byte("null")) } func collectFieldsToResolve(sels []selected.Selection, resolver reflect.Value, fields *[]*fieldToExec, fieldByAlias map[string]*fieldToExec) { @@ -206,7 +232,11 @@ func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *p if err != nil { r.AddError(err) - f.out.WriteString("null") // TODO handle non-nil + // Note that we write null here, but if this resolver is non-nullable and we've added an error, + // its parent ignores this output. We write the null here anyway just in case because + // we don't make decisions about the nullability of fields until the parent detects an error + // somewhere within the child's tree + f.out.WriteString("null") return } @@ -226,7 +256,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio return } - r.execSelections(ctx, sels, path, resolver, out, false) + r.execSelections(ctx, sels, path, resolver, out, false, nonNull) return } From 1f86e5976419233f6ccbf462580f11368765295a Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Tue, 19 Jun 2018 22:03:11 +0000 Subject: [PATCH 37/69] add error propagation tests --- gqltesting/subscriptions.go | 2 +- graphql_test.go | 193 ++++++++++++++++++++++++++++++++++-- 2 files changed, 188 insertions(+), 7 deletions(-) diff --git a/gqltesting/subscriptions.go b/gqltesting/subscriptions.go index b18ab6a5..1cdd3a51 100644 --- a/gqltesting/subscriptions.go +++ b/gqltesting/subscriptions.go @@ -71,7 +71,7 @@ func RunSubscribe(t *testing.T, test *TestSubscription) { } got, err := formatJSON(resData) if err != nil { - t.Fatalf("got: invalid JSON: %s", err) + t.Fatalf("got: invalid JSON: %s; raw: %s", err, resData) } expectedData, err := expected.Data.MarshalJSON() diff --git a/graphql_test.go b/graphql_test.go index 1e7267c1..d97ebb16 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -370,9 +370,7 @@ func TestErrorWithExtensions(t *testing.T) { } `, ExpectedResult: ` - { - "FindDroid": null - } + null `, ExpectedErrors: []*gqlerrors.QueryError{ &gqlerrors.QueryError{ @@ -406,9 +404,7 @@ func TestErrorWithNoExtensions(t *testing.T) { } `, ExpectedResult: ` - { - "DismissVader": null - } + null `, ExpectedErrors: []*gqlerrors.QueryError{ &gqlerrors.QueryError{ @@ -2049,3 +2045,188 @@ func TestComposedFragments(t *testing.T) { }, }) } + +var exampleErrorString = "This is an error" +var exampleError = fmt.Errorf(exampleErrorString) + +type errorringResolver1 struct{} + +func (r *errorringResolver1) TriggerError() (string, error) { + return "This will never be returned to the client", exampleError +} +func (r *errorringResolver1) NoError() string { + return "no error" +} +func (r *errorringResolver1) Child() *errorringResolver1 { + return &errorringResolver1{} +} + +type nonFailingRoot struct{} + +func (r *nonFailingRoot) Child() *errorringResolver1 { + return &errorringResolver1{} +} +func (r *nonFailingRoot) NoError() string { + return "no error" +} + +func TestErrorPropagation(t *testing.T) { + gqltesting.RunTests(t, []*gqltesting.Test{ + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + noError: String! + triggerError: String! + } + `, &errorringResolver1{}), + Query: ` + { + noError + triggerError + } + `, + ExpectedResult: ` + null + `, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: exampleErrorString, + ResolverError: exampleError, + Path: []interface{}{"triggerError"}, + }, + }, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + noError: String! + child: Child + } + + type Child { + noError: String! + triggerError: String! + } + `, &nonFailingRoot{}), + Query: ` + { + noError + child { + noError + triggerError + } + } + `, + ExpectedResult: ` + { + "noError": "no error", + "child": null + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: exampleErrorString, + ResolverError: exampleError, + Path: []interface{}{"child", "triggerError"}, + }, + }, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + noError: String! + child: Child + } + + type Child { + noError: String! + triggerError: String! + child: Child! + } + `, &nonFailingRoot{}), + Query: ` + { + noError + child { + noError + child { + noError + triggerError + } + } + } + `, + ExpectedResult: ` + { + "noError": "no error", + "child": null + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: exampleErrorString, + ResolverError: exampleError, + Path: []interface{}{"child", "child", "triggerError"}, + }, + }, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + noError: String! + child: Child + } + + type Child { + noError: String! + triggerError: String! + child: Child + } + `, &nonFailingRoot{}), + Query: ` + { + noError + child { + noError + child { + noError + triggerError + } + } + } + `, + ExpectedResult: ` + { + "noError": "no error", + "child": { + "noError": "no error", + "child": null + } + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: exampleErrorString, + ResolverError: exampleError, + Path: []interface{}{"child", "child", "triggerError"}, + }, + }, + }, + }) +} From 16262259e184e410344f8fb6f745b4c3c8047bf1 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Tue, 23 Oct 2018 01:19:03 +0000 Subject: [PATCH 38/69] handle subscription errors --- gqltesting/subscriptions.go | 2 +- internal/exec/subscribe.go | 10 +++++++--- subscription_test.go | 6 +----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/gqltesting/subscriptions.go b/gqltesting/subscriptions.go index 1cdd3a51..7a1cd0d1 100644 --- a/gqltesting/subscriptions.go +++ b/gqltesting/subscriptions.go @@ -80,7 +80,7 @@ func RunSubscribe(t *testing.T, test *TestSubscription) { } want, err := formatJSON(expectedData) if err != nil { - t.Fatalf("got: invalid JSON: %s", err) + t.Fatalf("got: invalid JSON: %s; raw: %s", err, expectedData) } if !bytes.Equal(got, want) { diff --git a/internal/exec/subscribe.go b/internal/exec/subscribe.go index 03826f89..0a366f31 100644 --- a/internal/exec/subscribe.go +++ b/internal/exec/subscribe.go @@ -115,9 +115,13 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *query func() { defer subR.handlePanic(subCtx) - out.WriteString(fmt.Sprintf(`{"%s":`, f.field.Alias)) - subR.execSelectionSet(subCtx, f.sels, f.field.Type, &pathSegment{nil, f.field.Alias}, resp, &out) - out.WriteString(`}`) + var buf bytes.Buffer + subR.execSelectionSet(subCtx, f.sels, f.field.Type, &pathSegment{nil, f.field.Alias}, resp, &buf) + if len(subR.Errs) == 0 { + out.WriteString(fmt.Sprintf(`{"%s":`, f.field.Alias)) + out.Write(buf.Bytes()) + out.WriteString(`}`) + } }() if err := subCtx.Err(); err != nil { diff --git a/subscription_test.go b/subscription_test.go index 803ee159..955a373b 100644 --- a/subscription_test.go +++ b/subscription_test.go @@ -100,11 +100,7 @@ func TestSchemaSubscribe(t *testing.T) { }, { Data: json.RawMessage(` - { - "helloSaid": { - "msg":null - } - } + null `), Errors: []*qerrors.QueryError{qerrors.Errorf("%s", resolverErr)}, }, From 84670bd82cc4936f8265a1687bcad9bc725935b6 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Tue, 23 Oct 2018 17:20:08 +0000 Subject: [PATCH 39/69] fix handling of nullables in subscriptions --- internal/exec/subscribe.go | 14 ++++- subscription_test.go | 105 +++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/internal/exec/subscribe.go b/internal/exec/subscribe.go index 0a366f31..63335e99 100644 --- a/internal/exec/subscribe.go +++ b/internal/exec/subscribe.go @@ -9,6 +9,7 @@ import ( "time" "github.com/graph-gophers/graphql-go/errors" + "github.com/graph-gophers/graphql-go/internal/common" "github.com/graph-gophers/graphql-go/internal/exec/resolvable" "github.com/graph-gophers/graphql-go/internal/exec/selected" "github.com/graph-gophers/graphql-go/internal/query" @@ -55,7 +56,10 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *query }() if err != nil { - return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{err}}) + if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild { + return sendAndReturnClosed(&Response{Errors: []*errors.QueryError{err}}) + } + return sendAndReturnClosed(&Response{Data: []byte(fmt.Sprintf(`{"%s":null}`, f.field.Alias)), Errors: []*errors.QueryError{err}}) } if ctxErr := ctx.Err(); ctxErr != nil { @@ -117,7 +121,13 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *query var buf bytes.Buffer subR.execSelectionSet(subCtx, f.sels, f.field.Type, &pathSegment{nil, f.field.Alias}, resp, &buf) - if len(subR.Errs) == 0 { + + propagateChildError := false + if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild && subR.SubPathHasError((&pathSegment{nil, f.field.Alias}).toSlice()) { + propagateChildError = true + } + + if !propagateChildError { out.WriteString(fmt.Sprintf(`{"%s":`, f.field.Alias)) out.Write(buf.Bytes()) out.WriteString(`}`) diff --git a/subscription_test.go b/subscription_test.go index 955a373b..62eb029d 100644 --- a/subscription_test.go +++ b/subscription_test.go @@ -14,6 +14,7 @@ import ( type rootResolver struct { *helloResolver *helloSaidResolver + *helloSaidNullableResolver } type helloResolver struct{} @@ -68,6 +69,50 @@ func closedUpstream(rr ...*helloSaidEventResolver) <-chan *helloSaidEventResolve return c } +type helloSaidNullableResolver struct { + err error + upstream <-chan *helloSaidNullableEventResolver +} + +type helloSaidNullableEventResolver struct { + msg *string + err error +} + +func (r *helloSaidNullableResolver) HelloSaidNullable(ctx context.Context) (chan *helloSaidNullableEventResolver, error) { + if r.err != nil { + return nil, r.err + } + + c := make(chan *helloSaidNullableEventResolver) + go func() { + for r := range r.upstream { + select { + case <-ctx.Done(): + close(c) + return + case c <- r: + } + } + close(c) + }() + + return c, nil +} + +func (r *helloSaidNullableEventResolver) Msg() (*string, error) { + return r.msg, r.err +} + +func closedUpstreamNullable(rr ...*helloSaidNullableEventResolver) <-chan *helloSaidNullableEventResolver { + c := make(chan *helloSaidNullableEventResolver, len(rr)) + for _, r := range rr { + c <- r + } + close(c) + return c +} + func TestSchemaSubscribe(t *testing.T) { gqltesting.RunSubscribes(t, []*gqltesting.TestSubscription{ { @@ -157,6 +202,61 @@ func TestSchemaSubscribe(t *testing.T) { `, ExpectedResults: []gqltesting.TestResponse{ { + Data: json.RawMessage(` + null + `), + Errors: []*qerrors.QueryError{qerrors.Errorf("%s", resolverErr)}, + }, + }, + }, + { + Name: "subscription_resolver_can_error_optional_msg", + Schema: graphql.MustParseSchema(schema, &rootResolver{ + helloSaidNullableResolver: &helloSaidNullableResolver{ + upstream: closedUpstreamNullable( + &helloSaidNullableEventResolver{err: resolverErr}, + ), + }, + }), + Query: ` + subscription onHelloSaid { + helloSaidNullable { + msg + } + } + `, + ExpectedResults: []gqltesting.TestResponse{ + { + Data: json.RawMessage(` + { + "helloSaidNullable": { + "msg": null + } + } + `), + Errors: []*qerrors.QueryError{qerrors.Errorf("%s", resolverErr)}, + }, + }, + }, + { + Name: "subscription_resolver_can_error_optional_event", + Schema: graphql.MustParseSchema(schema, &rootResolver{ + helloSaidNullableResolver: &helloSaidNullableResolver{err: resolverErr}, + }), + Query: ` + subscription onHelloSaid { + helloSaidNullable { + msg + } + } + `, + ExpectedResults: []gqltesting.TestResponse{ + { + Data: json.RawMessage(` + { + "helloSaidNullable": null + } + `), Errors: []*qerrors.QueryError{qerrors.Errorf("%s", resolverErr)}, }, }, @@ -184,12 +284,17 @@ const schema = ` type Subscription { helloSaid: HelloSaidEvent! + helloSaidNullable: HelloSaidEventNullable } type HelloSaidEvent { msg: String! } + type HelloSaidEventNullable { + msg: String + } + type Query { hello: String! } From af5c8a81f94828a483292a4fcc85981364b10ec9 Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Tue, 23 Oct 2018 20:00:25 +0000 Subject: [PATCH 40/69] test more edge cases of error propagation --- graphql_test.go | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/graphql_test.go b/graphql_test.go index d97ebb16..74af7ca5 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -102,6 +102,26 @@ func (r *findDroidResolver) FindDroid(ctx context.Context) (string, error) { } } +type findDroidOrHumanResolver struct{} + +func (r *findDroidOrHumanResolver) FindHuman(ctx context.Context) (*string, error) { + human := "human" + return &human, nil +} + +func (r *findDroidOrHumanResolver) FindDroid(ctx context.Context) (*droidResolver, error) { + return &droidResolver{}, resolverNotFoundError{ + Code: "NotFound", + Message: "This is not the droid you are looking for", + } +} + +type droidResolver struct{} + +func (d *droidResolver) Name() string { + return "R2D2" +} + type discussPlanResolver struct{} func (r *discussPlanResolver) DismissVader(ctx context.Context) (string, error) { @@ -361,12 +381,19 @@ func TestErrorWithExtensions(t *testing.T) { } type Query { - FindDroid: String! + FindDroid: Droid! + FindHuman: String + } + type Droid { + Name: String! } - `, &findDroidResolver{}), + `, &findDroidOrHumanResolver{}), Query: ` { - FindDroid + FindDroid { + Name + } + FindHuman } `, ExpectedResult: ` From 192cb83c3728eaf89d7ba9b7b86201bbb6ff75fb Mon Sep 17 00:00:00 2001 From: Grace Noah Date: Tue, 23 Oct 2018 20:09:42 +0000 Subject: [PATCH 41/69] add a test for slices --- graphql_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/graphql_test.go b/graphql_test.go index 74af7ca5..c5076f8e 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -102,6 +102,15 @@ func (r *findDroidResolver) FindDroid(ctx context.Context) (string, error) { } } +type findDroidsResolver struct{} + +func (r *findDroidsResolver) FindDroids(ctx context.Context) []*droidResolver { + return []*droidResolver{&droidResolver{}, &droidResolver{resolverNotFoundError{ + Code: "NotFound", + Message: "This is not the droid you are looking for", + }}} +} + type findDroidOrHumanResolver struct{} func (r *findDroidOrHumanResolver) FindHuman(ctx context.Context) (*string, error) { @@ -116,10 +125,13 @@ func (r *findDroidOrHumanResolver) FindDroid(ctx context.Context) (*droidResolve } } -type droidResolver struct{} +type droidResolver struct{ err error } -func (d *droidResolver) Name() string { - return "R2D2" +func (d *droidResolver) Name() (string, error) { + if d.err != nil { + return "", d.err + } + return "R2D2", nil } type discussPlanResolver struct{} @@ -367,6 +379,48 @@ func TestNilInterface(t *testing.T) { }) } +func TestErrorPropagationInLists(t *testing.T) { + err := resolverNotFoundError{ + Code: "NotFound", + Message: "This is not the droid you are looking for", + } + + gqltesting.RunTests(t, []*gqltesting.Test{ + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + FindDroids: [Droid!]! + } + type Droid { + Name: String! + } + `, &findDroidsResolver{}), + Query: ` + { + FindDroids { + Name + } + } + `, + ExpectedResult: ` + null + `, + ExpectedErrors: []*gqlerrors.QueryError{ + &gqlerrors.QueryError{ + Message: err.Error(), + Path: []interface{}{"FindDroids", 1, "Name"}, + ResolverError: err, + Extensions: map[string]interface{}{"code": err.Code, "message": err.Message}, + }, + }, + }, + }) +} + func TestErrorWithExtensions(t *testing.T) { err := resolverNotFoundError{ Code: "NotFound", From c948eab40a3d6837f2ddfca265e6df1b35ac254d Mon Sep 17 00:00:00 2001 From: Tiffany Wang Date: Wed, 12 Dec 2018 13:24:34 -0800 Subject: [PATCH 42/69] propagate errors when non-nullable fields resolve to null --- graphql_test.go | 191 +++++++++++++++++++++++++++++++++++++++--- internal/exec/exec.go | 5 +- 2 files changed, 185 insertions(+), 11 deletions(-) diff --git a/graphql_test.go b/graphql_test.go index c5076f8e..6451e541 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -2127,25 +2127,32 @@ func TestComposedFragments(t *testing.T) { }) } -var exampleErrorString = "This is an error" -var exampleError = fmt.Errorf(exampleErrorString) +var ( + exampleErrorString = "This is an error" + exampleError = fmt.Errorf(exampleErrorString) -type errorringResolver1 struct{} + nilChildErrorString = `got nil for non-null "Child"` +) + +type erroringResolver1 struct{} -func (r *errorringResolver1) TriggerError() (string, error) { +func (r *erroringResolver1) TriggerError() (string, error) { return "This will never be returned to the client", exampleError } -func (r *errorringResolver1) NoError() string { +func (r *erroringResolver1) NoError() string { return "no error" } -func (r *errorringResolver1) Child() *errorringResolver1 { - return &errorringResolver1{} +func (r *erroringResolver1) Child() *erroringResolver1 { + return &erroringResolver1{} +} +func (r *erroringResolver1) NilChild() *erroringResolver1 { + return nil } type nonFailingRoot struct{} -func (r *nonFailingRoot) Child() *errorringResolver1 { - return &errorringResolver1{} +func (r *nonFailingRoot) Child() *erroringResolver1 { + return &erroringResolver1{} } func (r *nonFailingRoot) NoError() string { return "no error" @@ -2163,7 +2170,7 @@ func TestErrorPropagation(t *testing.T) { noError: String! triggerError: String! } - `, &errorringResolver1{}), + `, &erroringResolver1{}), Query: ` { noError @@ -2309,5 +2316,169 @@ func TestErrorPropagation(t *testing.T) { }, }, }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + noError: String! + child: Child! + } + + type Child { + noError: String! + nilChild: Child! + } + `, &nonFailingRoot{}), + Query: ` + { + noError + child { + nilChild { + noError + } + } + } + `, + ExpectedResult: ` + null + `, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: nilChildErrorString, + Path: []interface{}{"child", "nilChild"}, + }, + }, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + noError: String! + child: Child + } + + type Child { + noError: String! + nilChild: Child! + } + `, &nonFailingRoot{}), + Query: ` + { + noError + child { + noError + nilChild { + noError + } + } + } + `, + ExpectedResult: ` + { + "noError": "no error", + "child": null + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: nilChildErrorString, + Path: []interface{}{"child", "nilChild"}, + }, + }, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + child: Child + } + + type Child { + triggerError: String! + child: Child + nilChild: Child! + } + `, &nonFailingRoot{}), + Query: ` + { + child { + child { + triggerError + child { + nilChild { + triggerError + } + } + } + } + } + `, + ExpectedResult: ` + { + "child": { + "child": null + } + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: nilChildErrorString, + Path: []interface{}{"child", "child", "child", "nilChild"}, + }, + { + Message: exampleErrorString, + ResolverError: exampleError, + Path: []interface{}{"child", "child", "triggerError"}, + }, + }, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + child: Child + } + + type Child { + noError: String! + child: Child! + nilChild: Child! + } + `, &nonFailingRoot{}), + Query: ` + { + child { + child { + nilChild { + noError + } + } + } + } + `, + ExpectedResult: ` + { + "child": null + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: nilChildErrorString, + Path: []interface{}{"child", "child", "nilChild"}, + }, + }, + }, }) } diff --git a/internal/exec/exec.go b/internal/exec/exec.go index f5855fb0..4ad3755c 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -249,8 +249,11 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio case *schema.Object, *schema.Interface, *schema.Union: // a reflect.Value of a nil interface will show up as an Invalid value if resolver.Kind() == reflect.Invalid || ((resolver.Kind() == reflect.Ptr || resolver.Kind() == reflect.Interface) && resolver.IsNil()) { + // If a field of a non-null type resolves to nil, add an error and propagate it if nonNull { - panic(errors.Errorf("got nil for non-null %q", t)) + err := errors.Errorf("got nil for non-null %q", t) + err.Path = path.toSlice() + r.AddError(err) } out.WriteString("null") return From 7e5a06c95979fffd87b15b8fcc1e1b73dff2fb34 Mon Sep 17 00:00:00 2001 From: Tiffany Wang Date: Wed, 12 Dec 2018 14:03:26 -0800 Subject: [PATCH 43/69] sort errors for deterministic test results --- gqltesting/testing.go | 14 ++++++++++++++ graphql_test.go | 2 +- internal/exec/exec.go | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/gqltesting/testing.go b/gqltesting/testing.go index 5f4e634d..dc89fdc4 100644 --- a/gqltesting/testing.go +++ b/gqltesting/testing.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "encoding/json" + "fmt" "reflect" + "sort" "strconv" "testing" @@ -76,7 +78,19 @@ func formatJSON(data []byte) ([]byte, error) { } func checkErrors(t *testing.T, want, got []*errors.QueryError) { + sortErrors(want) + sortErrors(got) + if !reflect.DeepEqual(got, want) { t.Fatalf("unexpected error: got %+v, want %+v", got, want) } } + +func sortErrors(errors []*errors.QueryError) { + if len(errors) <= 1 { + return + } + sort.Slice(errors, func(i, j int) bool { + return fmt.Sprintf("%s", errors[i].Path) < fmt.Sprintf("%s", errors[j].Path) + }) +} diff --git a/graphql_test.go b/graphql_test.go index 6451e541..d3ec99b4 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -2131,7 +2131,7 @@ var ( exampleErrorString = "This is an error" exampleError = fmt.Errorf(exampleErrorString) - nilChildErrorString = `got nil for non-null "Child"` + nilChildErrorString = `graphql: got nil for non-null "Child"` ) type erroringResolver1 struct{} diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 4ad3755c..025f1fc2 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -251,7 +251,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio if resolver.Kind() == reflect.Invalid || ((resolver.Kind() == reflect.Ptr || resolver.Kind() == reflect.Interface) && resolver.IsNil()) { // If a field of a non-null type resolves to nil, add an error and propagate it if nonNull { - err := errors.Errorf("got nil for non-null %q", t) + err := errors.Errorf("graphql: got nil for non-null %q", t) err.Path = path.toSlice() r.AddError(err) } From 0009cb07611654afda89cd32693b179ecb55d212 Mon Sep 17 00:00:00 2001 From: Tiffany Wang Date: Wed, 12 Dec 2018 16:41:54 -0800 Subject: [PATCH 44/69] more tests for error propagation in lists --- graphql_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/graphql_test.go b/graphql_test.go index d3ec99b4..acbb7593 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -111,6 +111,10 @@ func (r *findDroidsResolver) FindDroids(ctx context.Context) []*droidResolver { }}} } +func (r *findDroidsResolver) FindNilDroids(ctx context.Context) *[]*droidResolver { + return &[]*droidResolver{&droidResolver{}, nil, &droidResolver{}} +} + type findDroidOrHumanResolver struct{} func (r *findDroidOrHumanResolver) FindHuman(ctx context.Context) (*string, error) { @@ -418,6 +422,38 @@ func TestErrorPropagationInLists(t *testing.T) { }, }, }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + FindNilDroids: [Droid!] + } + type Droid { + Name: String! + } + `, &findDroidsResolver{}), + Query: ` + { + FindNilDroids { + Name + } + } + `, + ExpectedResult: ` + { + "FindNilDroids": null + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + &gqlerrors.QueryError{ + Message: `graphql: got nil for non-null "Droid"`, + Path: []interface{}{"FindNilDroids", 1}, + }, + }, + }, }) } From 6d096758e96ee9ebfbbb38b0cdb5498a9da1ba9c Mon Sep 17 00:00:00 2001 From: Tiffany Wang Date: Wed, 12 Dec 2018 19:46:22 -0800 Subject: [PATCH 45/69] propagate errors in lists --- internal/exec/exec.go | 84 +++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 025f1fc2..6da505de 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -273,40 +273,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio switch t := t.(type) { case *common.List: - l := resolver.Len() - - if selected.HasAsyncSel(sels) { - var wg sync.WaitGroup - wg.Add(l) - entryouts := make([]bytes.Buffer, l) - for i := 0; i < l; i++ { - go func(i int) { - defer wg.Done() - defer r.handlePanic(ctx) - r.execSelectionSet(ctx, sels, t.OfType, &pathSegment{path, i}, resolver.Index(i), &entryouts[i]) - }(i) - } - wg.Wait() - - out.WriteByte('[') - for i, entryout := range entryouts { - if i > 0 { - out.WriteByte(',') - } - out.Write(entryout.Bytes()) - } - out.WriteByte(']') - return - } - - out.WriteByte('[') - for i := 0; i < l; i++ { - if i > 0 { - out.WriteByte(',') - } - r.execSelectionSet(ctx, sels, t.OfType, &pathSegment{path, i}, resolver.Index(i), out) - } - out.WriteByte(']') + r.execList(ctx, sels, t, path, resolver, out) case *schema.Scalar: v := resolver.Interface() @@ -330,6 +297,55 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio } } +func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ *common.List, path *pathSegment, resolver reflect.Value, out *bytes.Buffer) { + // If the list wraps a non-null type and one of the list elements resolves to nil, + // then the entire list resolves to nil + defer func() { + if _, ok := typ.OfType.(*common.NonNull); !ok { + return + } + if r.SubPathHasError(path.toSlice()) { + out.Reset() + out.WriteString("null") + } + }() + + l := resolver.Len() + + if selected.HasAsyncSel(sels) { + var wg sync.WaitGroup + wg.Add(l) + entryouts := make([]bytes.Buffer, l) + for i := 0; i < l; i++ { + go func(i int) { + defer wg.Done() + defer r.handlePanic(ctx) + r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, resolver.Index(i), &entryouts[i]) + }(i) + } + wg.Wait() + + out.WriteByte('[') + for i, entryout := range entryouts { + if i > 0 { + out.WriteByte(',') + } + out.Write(entryout.Bytes()) + } + out.WriteByte(']') + return + } + + out.WriteByte('[') + for i := 0; i < l; i++ { + if i > 0 { + out.WriteByte(',') + } + r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, resolver.Index(i), out) + } + out.WriteByte(']') +} + func unwrapNonNull(t common.Type) (common.Type, bool) { if nn, ok := t.(*common.NonNull); ok { return nn.OfType, true From 5cfcb769dc44553ab026b51fd04e7b7784a3aac1 Mon Sep 17 00:00:00 2001 From: Tiffany Wang Date: Thu, 13 Dec 2018 02:55:59 -0800 Subject: [PATCH 46/69] more tests --- graphql_test.go | 244 +++++++++++++++++++++++++++++++++++------- internal/exec/exec.go | 35 +++--- 2 files changed, 221 insertions(+), 58 deletions(-) diff --git a/graphql_test.go b/graphql_test.go index acbb7593..8e3b25e2 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -102,17 +102,26 @@ func (r *findDroidResolver) FindDroid(ctx context.Context) (string, error) { } } +var ( + droidNotFoundError = resolverNotFoundError{ + Code: "NotFound", + Message: "This is not the droid you are looking for", + } + quoteError = errors.New("Bleep bloop") + + r2d2 = &droidResolver{name: "R2-D2"} + c3po = &droidResolver{name: "C-3PO"} + notFoundDroid = &droidResolver{err: droidNotFoundError} +) + type findDroidsResolver struct{} func (r *findDroidsResolver) FindDroids(ctx context.Context) []*droidResolver { - return []*droidResolver{&droidResolver{}, &droidResolver{resolverNotFoundError{ - Code: "NotFound", - Message: "This is not the droid you are looking for", - }}} + return []*droidResolver{r2d2, notFoundDroid, c3po} } func (r *findDroidsResolver) FindNilDroids(ctx context.Context) *[]*droidResolver { - return &[]*droidResolver{&droidResolver{}, nil, &droidResolver{}} + return &[]*droidResolver{r2d2, nil, c3po} } type findDroidOrHumanResolver struct{} @@ -123,19 +132,29 @@ func (r *findDroidOrHumanResolver) FindHuman(ctx context.Context) (*string, erro } func (r *findDroidOrHumanResolver) FindDroid(ctx context.Context) (*droidResolver, error) { - return &droidResolver{}, resolverNotFoundError{ - Code: "NotFound", - Message: "This is not the droid you are looking for", - } + return nil, notFoundDroid.err } -type droidResolver struct{ err error } +type droidResolver struct { + name string + err error +} func (d *droidResolver) Name() (string, error) { if d.err != nil { return "", d.err } - return "R2D2", nil + return d.name, nil +} + +func (d *droidResolver) Quotes() ([]string, error) { + switch d.name { + case "R2-D2": + return nil, quoteError + case "C-3PO": + return []string{"We're doomed!", "R2-D2, where are you?"}, nil + } + return nil, nil } type discussPlanResolver struct{} @@ -384,11 +403,6 @@ func TestNilInterface(t *testing.T) { } func TestErrorPropagationInLists(t *testing.T) { - err := resolverNotFoundError{ - Code: "NotFound", - Message: "This is not the droid you are looking for", - } - gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -397,16 +411,16 @@ func TestErrorPropagationInLists(t *testing.T) { } type Query { - FindDroids: [Droid!]! + findDroids: [Droid!]! } type Droid { - Name: String! + name: String! } `, &findDroidsResolver{}), Query: ` { - FindDroids { - Name + findDroids { + name } } `, @@ -415,10 +429,10 @@ func TestErrorPropagationInLists(t *testing.T) { `, ExpectedErrors: []*gqlerrors.QueryError{ &gqlerrors.QueryError{ - Message: err.Error(), - Path: []interface{}{"FindDroids", 1, "Name"}, - ResolverError: err, - Extensions: map[string]interface{}{"code": err.Code, "message": err.Message}, + Message: droidNotFoundError.Error(), + Path: []interface{}{"findDroids", 1, "name"}, + ResolverError: droidNotFoundError, + Extensions: map[string]interface{}{"code": droidNotFoundError.Code, "message": droidNotFoundError.Message}, }, }, }, @@ -429,28 +443,187 @@ func TestErrorPropagationInLists(t *testing.T) { } type Query { - FindNilDroids: [Droid!] + findDroids: [Droid]! } type Droid { - Name: String! + name: String! } `, &findDroidsResolver{}), Query: ` { - FindNilDroids { - Name + findDroids { + name } } `, ExpectedResult: ` { - "FindNilDroids": null + "findDroids": [ + { + "name": "R2-D2" + }, + null, + { + "name": "C-3PO" + } + ] + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + &gqlerrors.QueryError{ + Message: droidNotFoundError.Error(), + Path: []interface{}{"findDroids", 1, "name"}, + ResolverError: droidNotFoundError, + Extensions: map[string]interface{}{"code": droidNotFoundError.Code, "message": droidNotFoundError.Message}, + }, + }, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + findNilDroids: [Droid!] + } + type Droid { + name: String! + } + `, &findDroidsResolver{}), + Query: ` + { + findNilDroids { + name + } + } + `, + ExpectedResult: ` + { + "findNilDroids": null } `, ExpectedErrors: []*gqlerrors.QueryError{ &gqlerrors.QueryError{ Message: `graphql: got nil for non-null "Droid"`, - Path: []interface{}{"FindNilDroids", 1}, + Path: []interface{}{"findNilDroids", 1}, + }, + }, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + findNilDroids: [Droid] + } + type Droid { + name: String! + } + `, &findDroidsResolver{}), + Query: ` + { + findNilDroids { + name + } + } + `, + ExpectedResult: ` + { + "findNilDroids": [ + { + "name": "R2-D2" + }, + null, + { + "name": "C-3PO" + } + ] + } + `, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + findDroids: [Droid]! + } + type Droid { + quotes: [String!]! + } + `, &findDroidsResolver{}), + Query: ` + { + findDroids { + quotes + } + } + `, + ExpectedResult: ` + { + "findDroids": [ + null, + { + "quotes": [] + }, + { + "quotes": [ + "We're doomed!", + "R2-D2, where are you?" + ] + } + ] + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + &gqlerrors.QueryError{ + Message: quoteError.Error(), + ResolverError: quoteError, + Path: []interface{}{"findDroids", 0, "quotes"}, + }, + }, + }, + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + findNilDroids: [Droid!] + } + type Droid { + name: String! + quotes: [String!]! + } + `, &findDroidsResolver{}), + Query: ` + { + findNilDroids { + name + quotes + } + } + `, + ExpectedResult: ` + { + "findNilDroids": null + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + &gqlerrors.QueryError{ + Message: quoteError.Error(), + ResolverError: quoteError, + Path: []interface{}{"findNilDroids", 0, "quotes"}, + }, + &gqlerrors.QueryError{ + Message: `graphql: got nil for non-null "Droid"`, + Path: []interface{}{"findNilDroids", 1}, }, }, }, @@ -458,11 +631,6 @@ func TestErrorPropagationInLists(t *testing.T) { } func TestErrorWithExtensions(t *testing.T) { - err := resolverNotFoundError{ - Code: "NotFound", - Message: "This is not the droid you are looking for", - } - gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -491,10 +659,10 @@ func TestErrorWithExtensions(t *testing.T) { `, ExpectedErrors: []*gqlerrors.QueryError{ &gqlerrors.QueryError{ - Message: err.Error(), + Message: droidNotFoundError.Error(), Path: []interface{}{"FindDroid"}, - ResolverError: err, - Extensions: map[string]interface{}{"code": err.Code, "message": err.Message}, + ResolverError: droidNotFoundError, + Extensions: map[string]interface{}{"code": droidNotFoundError.Code, "message": droidNotFoundError.Message}, }, }, }, diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 6da505de..52fdd9a7 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -298,18 +298,6 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio } func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ *common.List, path *pathSegment, resolver reflect.Value, out *bytes.Buffer) { - // If the list wraps a non-null type and one of the list elements resolves to nil, - // then the entire list resolves to nil - defer func() { - if _, ok := typ.OfType.(*common.NonNull); !ok { - return - } - if r.SubPathHasError(path.toSlice()) { - out.Reset() - out.WriteString("null") - } - }() - l := resolver.Len() if selected.HasAsyncSel(sels) { @@ -333,17 +321,24 @@ func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ * out.Write(entryout.Bytes()) } out.WriteByte(']') - return + } else { + out.WriteByte('[') + for i := 0; i < l; i++ { + if i > 0 { + out.WriteByte(',') + } + r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, resolver.Index(i), out) + } + out.WriteByte(']') } - out.WriteByte('[') - for i := 0; i < l; i++ { - if i > 0 { - out.WriteByte(',') - } - r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, resolver.Index(i), out) + // If the list wraps a non-null type and one of the list elements + // resolves to null, then the entire list resolves to null + _, ok := typ.OfType.(*common.NonNull) + if ok && r.SubPathHasError(path.toSlice()) { + out.Reset() + out.WriteString("null") } - out.WriteByte(']') } func unwrapNonNull(t common.Type) (common.Type, bool) { From 12c3e9548470c9049527d911d83685f2a477c3d6 Mon Sep 17 00:00:00 2001 From: Tiffany Wang Date: Thu, 13 Dec 2018 20:17:30 -0800 Subject: [PATCH 47/69] check nullability of immediate children --- internal/exec/exec.go | 100 +++++++++++++---------------- internal/exec/selected/selected.go | 25 -------- internal/exec/subscribe.go | 2 +- 3 files changed, 47 insertions(+), 80 deletions(-) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 52fdd9a7..b7732a65 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -62,6 +62,10 @@ type fieldToExec struct { out *bytes.Buffer } +func resolvedToNull(b *bytes.Buffer) bool { + return bytes.Equal(b.Bytes(), []byte("null")) +} + func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, serially bool, isNonNull bool) { async := !serially && selected.HasAsyncSel(sels) @@ -87,44 +91,37 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, } } - // | nullable field | non-nullable field + // | nullable field | non-nullable field // ------------------------------------------------------------------------------- - // non-nullable child has error | print null | print nothing, wait for parent to print null - // no non-nullable child error | print output | print output + // non-nullable child is null | print null | print nothing, wait for parent to print null + // no non-nullable child is null | print output | print output - childHasError := false + propagateChildError := false for _, f := range fields { - if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild && r.SubPathHasError((&pathSegment{path, f.field.Alias}).toSlice()) { - childHasError = true + if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild && resolvedToNull(f.out) { + propagateChildError = true break } } - // If the child has no error, we simply write out the results - if !childHasError { - out.WriteByte('{') - for i, f := range fields { - if i > 0 { - out.WriteByte(',') - } - out.WriteByte('"') - out.WriteString(f.field.Alias) - out.WriteByte('"') - out.WriteByte(':') - out.Write(f.out.Bytes()) - } - out.WriteByte('}') + // If a non-nullable child is null, its parent resolves to null + if propagateChildError { + out.Write([]byte("null")) return } - // We exit early in the non-null case because we want the parent to write out its response instead - // and an error has already been set, indicating to the parent that it should become null - if isNonNull { - return + out.WriteByte('{') + for i, f := range fields { + if i > 0 { + out.WriteByte(',') + } + out.WriteByte('"') + out.WriteString(f.field.Alias) + out.WriteByte('"') + out.WriteByte(':') + out.Write(f.out.Bytes()) } - - // If there's an error and the current field is nullable, we write out a null - out.Write([]byte("null")) + out.WriteByte('}') } func collectFieldsToResolve(sels []selected.Selection, resolver reflect.Value, fields *[]*fieldToExec, fieldByAlias map[string]*fieldToExec) { @@ -231,11 +228,9 @@ func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *p } if err != nil { + // If an error is thrown while resolving a field, it should be treated as though the field + // returned null, and an error must be added to the "errors" list in the response r.AddError(err) - // Note that we write null here, but if this resolver is non-nullable and we've added an error, - // its parent ignores this output. We write the null here anyway just in case because - // we don't make decisions about the nullability of fields until the parent detects an error - // somewhere within the child's tree f.out.WriteString("null") return } @@ -249,7 +244,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio case *schema.Object, *schema.Interface, *schema.Union: // a reflect.Value of a nil interface will show up as an Invalid value if resolver.Kind() == reflect.Invalid || ((resolver.Kind() == reflect.Ptr || resolver.Kind() == reflect.Interface) && resolver.IsNil()) { - // If a field of a non-null type resolves to nil, add an error and propagate it + // If a field of a non-null type resolves to null, add an error and propagate it if nonNull { err := errors.Errorf("graphql: got nil for non-null %q", t) err.Path = path.toSlice() @@ -299,11 +294,11 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ *common.List, path *pathSegment, resolver reflect.Value, out *bytes.Buffer) { l := resolver.Len() + entryouts := make([]bytes.Buffer, l) if selected.HasAsyncSel(sels) { var wg sync.WaitGroup wg.Add(l) - entryouts := make([]bytes.Buffer, l) for i := 0; i < l; i++ { go func(i int) { defer wg.Done() @@ -312,33 +307,30 @@ func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ * }(i) } wg.Wait() - - out.WriteByte('[') - for i, entryout := range entryouts { - if i > 0 { - out.WriteByte(',') - } - out.Write(entryout.Bytes()) - } - out.WriteByte(']') } else { - out.WriteByte('[') for i := 0; i < l; i++ { - if i > 0 { - out.WriteByte(',') - } - r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, resolver.Index(i), out) + r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, resolver.Index(i), &entryouts[i]) } - out.WriteByte(']') } - // If the list wraps a non-null type and one of the list elements - // resolves to null, then the entire list resolves to null - _, ok := typ.OfType.(*common.NonNull) - if ok && r.SubPathHasError(path.toSlice()) { - out.Reset() - out.WriteString("null") + _, listOfNonNull := typ.OfType.(*common.NonNull) + + out.WriteByte('[') + for i, entryout := range entryouts { + // If the list wraps a non-null type and one of the list elements + // resolves to null, then the entire list resolves to null + if listOfNonNull && resolvedToNull(&entryout) { + out.Reset() + out.WriteString("null") + return + } + + if i > 0 { + out.WriteByte(',') + } + out.Write(entryout.Bytes()) } + out.WriteByte(']') } func unwrapNonNull(t common.Type) (common.Type, bool) { diff --git a/internal/exec/selected/selected.go b/internal/exec/selected/selected.go index bd33efa7..2a957f55 100644 --- a/internal/exec/selected/selected.go +++ b/internal/exec/selected/selected.go @@ -28,31 +28,6 @@ func (r *Request) AddError(err *errors.QueryError) { r.Mu.Unlock() } -// pathPrefixMatch checks if `path` starts with `prefix` -func pathPrefixMatch(path []interface{}, prefix []interface{}) bool { - if len(prefix) > len(path) { - return false - } - for i, component := range prefix { - if path[i] != component { - return false - } - } - return true -} - -// SubPathHasError returns true if any path that is a subpath of the given path has added errors to the response -func (r *Request) SubPathHasError(path []interface{}) bool { - r.Mu.Lock() - defer r.Mu.Unlock() - for _, err := range r.Errs { - if pathPrefixMatch(err.Path, path) { - return true - } - } - return false -} - func ApplyOperation(r *Request, s *resolvable.Schema, op *query.Operation) []Selection { var obj *resolvable.Object switch op.Type { diff --git a/internal/exec/subscribe.go b/internal/exec/subscribe.go index 63335e99..b9ac6327 100644 --- a/internal/exec/subscribe.go +++ b/internal/exec/subscribe.go @@ -123,7 +123,7 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *query subR.execSelectionSet(subCtx, f.sels, f.field.Type, &pathSegment{nil, f.field.Alias}, resp, &buf) propagateChildError := false - if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild && subR.SubPathHasError((&pathSegment{nil, f.field.Alias}).toSlice()) { + if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild && resolvedToNull(&buf) { propagateChildError = true } From 08598f5d7d1d8953191e7a7d994debff38242416 Mon Sep 17 00:00:00 2001 From: Tiffany Wang Date: Tue, 18 Dec 2018 01:27:57 -0800 Subject: [PATCH 48/69] cleanup --- graphql_test.go | 54 ++++++++++++++++++------------------------- internal/exec/exec.go | 44 +++++++++++++++-------------------- 2 files changed, 40 insertions(+), 58 deletions(-) diff --git a/graphql_test.go b/graphql_test.go index 8e3b25e2..201ce6dc 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -149,9 +149,9 @@ func (d *droidResolver) Name() (string, error) { func (d *droidResolver) Quotes() ([]string, error) { switch d.name { - case "R2-D2": + case r2d2.name: return nil, quoteError - case "C-3PO": + case c3po.name: return []string{"We're doomed!", "R2-D2, where are you?"}, nil } return nil, nil @@ -2332,36 +2332,26 @@ func TestComposedFragments(t *testing.T) { } var ( - exampleErrorString = "This is an error" - exampleError = fmt.Errorf(exampleErrorString) + exampleError = fmt.Errorf("This is an error") nilChildErrorString = `graphql: got nil for non-null "Child"` ) -type erroringResolver1 struct{} +type childResolver struct{} -func (r *erroringResolver1) TriggerError() (string, error) { +func (r *childResolver) TriggerError() (string, error) { return "This will never be returned to the client", exampleError } -func (r *erroringResolver1) NoError() string { +func (r *childResolver) NoError() string { return "no error" } -func (r *erroringResolver1) Child() *erroringResolver1 { - return &erroringResolver1{} +func (r *childResolver) Child() *childResolver { + return &childResolver{} } -func (r *erroringResolver1) NilChild() *erroringResolver1 { +func (r *childResolver) NilChild() *childResolver { return nil } -type nonFailingRoot struct{} - -func (r *nonFailingRoot) Child() *erroringResolver1 { - return &erroringResolver1{} -} -func (r *nonFailingRoot) NoError() string { - return "no error" -} - func TestErrorPropagation(t *testing.T) { gqltesting.RunTests(t, []*gqltesting.Test{ { @@ -2374,7 +2364,7 @@ func TestErrorPropagation(t *testing.T) { noError: String! triggerError: String! } - `, &erroringResolver1{}), + `, &childResolver{}), Query: ` { noError @@ -2386,7 +2376,7 @@ func TestErrorPropagation(t *testing.T) { `, ExpectedErrors: []*gqlerrors.QueryError{ { - Message: exampleErrorString, + Message: exampleError.Error(), ResolverError: exampleError, Path: []interface{}{"triggerError"}, }, @@ -2407,7 +2397,7 @@ func TestErrorPropagation(t *testing.T) { noError: String! triggerError: String! } - `, &nonFailingRoot{}), + `, &childResolver{}), Query: ` { noError @@ -2425,7 +2415,7 @@ func TestErrorPropagation(t *testing.T) { `, ExpectedErrors: []*gqlerrors.QueryError{ { - Message: exampleErrorString, + Message: exampleError.Error(), ResolverError: exampleError, Path: []interface{}{"child", "triggerError"}, }, @@ -2447,7 +2437,7 @@ func TestErrorPropagation(t *testing.T) { triggerError: String! child: Child! } - `, &nonFailingRoot{}), + `, &childResolver{}), Query: ` { noError @@ -2468,7 +2458,7 @@ func TestErrorPropagation(t *testing.T) { `, ExpectedErrors: []*gqlerrors.QueryError{ { - Message: exampleErrorString, + Message: exampleError.Error(), ResolverError: exampleError, Path: []interface{}{"child", "child", "triggerError"}, }, @@ -2490,7 +2480,7 @@ func TestErrorPropagation(t *testing.T) { triggerError: String! child: Child } - `, &nonFailingRoot{}), + `, &childResolver{}), Query: ` { noError @@ -2514,7 +2504,7 @@ func TestErrorPropagation(t *testing.T) { `, ExpectedErrors: []*gqlerrors.QueryError{ { - Message: exampleErrorString, + Message: exampleError.Error(), ResolverError: exampleError, Path: []interface{}{"child", "child", "triggerError"}, }, @@ -2535,7 +2525,7 @@ func TestErrorPropagation(t *testing.T) { noError: String! nilChild: Child! } - `, &nonFailingRoot{}), + `, &childResolver{}), Query: ` { noError @@ -2571,7 +2561,7 @@ func TestErrorPropagation(t *testing.T) { noError: String! nilChild: Child! } - `, &nonFailingRoot{}), + `, &childResolver{}), Query: ` { noError @@ -2611,7 +2601,7 @@ func TestErrorPropagation(t *testing.T) { child: Child nilChild: Child! } - `, &nonFailingRoot{}), + `, &childResolver{}), Query: ` { child { @@ -2639,7 +2629,7 @@ func TestErrorPropagation(t *testing.T) { Path: []interface{}{"child", "child", "child", "nilChild"}, }, { - Message: exampleErrorString, + Message: exampleError.Error(), ResolverError: exampleError, Path: []interface{}{"child", "child", "triggerError"}, }, @@ -2660,7 +2650,7 @@ func TestErrorPropagation(t *testing.T) { child: Child! nilChild: Child! } - `, &nonFailingRoot{}), + `, &childResolver{}), Query: ` { child { diff --git a/internal/exec/exec.go b/internal/exec/exec.go index b7732a65..ab056aae 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -45,7 +45,7 @@ func (r *Request) Execute(ctx context.Context, s *resolvable.Schema, op *query.O func() { defer r.handlePanic(ctx) sels := selected.ApplyOperation(&r.Request, s, op) - r.execSelections(ctx, sels, nil, s.Resolver, &out, op.Type == query.Mutation, false) + r.execSelections(ctx, sels, nil, s.Resolver, &out, op.Type == query.Mutation) }() if err := ctx.Err(); err != nil { @@ -66,7 +66,7 @@ func resolvedToNull(b *bytes.Buffer) bool { return bytes.Equal(b.Bytes(), []byte("null")) } -func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, serially bool, isNonNull bool) { +func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, serially bool) { async := !serially && selected.HasAsyncSel(sels) var fields []*fieldToExec @@ -91,27 +91,17 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, } } - // | nullable field | non-nullable field - // ------------------------------------------------------------------------------- - // non-nullable child is null | print null | print nothing, wait for parent to print null - // no non-nullable child is null | print output | print output - - propagateChildError := false - for _, f := range fields { - if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild && resolvedToNull(f.out) { - propagateChildError = true - break - } - } - - // If a non-nullable child is null, its parent resolves to null - if propagateChildError { - out.Write([]byte("null")) - return - } - out.WriteByte('{') for i, f := range fields { + // If a non-nullable child resolved to null, an error was added to the + // "errors" list in the response, so this field resolves to null. + // If this field is non-nullable, the error is propagated to its parent. + if _, ok := f.field.Type.(*common.NonNull); ok && resolvedToNull(f.out) { + out.Reset() + out.Write([]byte("null")) + return + } + if i > 0 { out.WriteByte(',') } @@ -228,8 +218,8 @@ func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *p } if err != nil { - // If an error is thrown while resolving a field, it should be treated as though the field - // returned null, and an error must be added to the "errors" list in the response + // If an error occurred while resolving a field, it should be treated as though the field + // returned null, and an error must be added to the "errors" list in the response. r.AddError(err) f.out.WriteString("null") return @@ -244,7 +234,9 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio case *schema.Object, *schema.Interface, *schema.Union: // a reflect.Value of a nil interface will show up as an Invalid value if resolver.Kind() == reflect.Invalid || ((resolver.Kind() == reflect.Ptr || resolver.Kind() == reflect.Interface) && resolver.IsNil()) { - // If a field of a non-null type resolves to null, add an error and propagate it + // If a field of a non-null type resolves to null (either because the + // function to resolve the field returned null or because an error occurred), + // add an error to the "errors" list in the response. if nonNull { err := errors.Errorf("graphql: got nil for non-null %q", t) err.Path = path.toSlice() @@ -254,7 +246,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio return } - r.execSelections(ctx, sels, path, resolver, out, false, nonNull) + r.execSelections(ctx, sels, path, resolver, out, false) return } @@ -318,7 +310,7 @@ func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ * out.WriteByte('[') for i, entryout := range entryouts { // If the list wraps a non-null type and one of the list elements - // resolves to null, then the entire list resolves to null + // resolves to null, then the entire list resolves to null. if listOfNonNull && resolvedToNull(&entryout) { out.Reset() out.WriteString("null") From 07f2eb0208961ca821f684def98a5357393a6f69 Mon Sep 17 00:00:00 2001 From: Joe Fitzgerald Date: Mon, 31 Dec 2018 14:53:04 -0700 Subject: [PATCH 49/69] convert to go module --- Gopkg.lock | 25 ------------------------- Gopkg.toml | 10 ---------- go.mod | 6 ++++++ go.sum | 4 ++++ 4 files changed, 10 insertions(+), 35 deletions(-) delete mode 100644 Gopkg.lock delete mode 100644 Gopkg.toml create mode 100644 go.mod create mode 100644 go.sum diff --git a/Gopkg.lock b/Gopkg.lock deleted file mode 100644 index 4574275c..00000000 --- a/Gopkg.lock +++ /dev/null @@ -1,25 +0,0 @@ -# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. - - -[[projects]] - name = "github.com/opentracing/opentracing-go" - packages = [ - ".", - "ext", - "log" - ] - revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" - version = "v1.0.2" - -[[projects]] - branch = "master" - name = "golang.org/x/net" - packages = ["context"] - revision = "f5dfe339be1d06f81b22525fe34671ee7d2c8904" - -[solve-meta] - analyzer-name = "dep" - analyzer-version = 1 - inputs-digest = "f417062128566756a9360b1c13ada79bdeeb6bab1f53ee9147a3328d95c1653f" - solver-name = "gps-cdcl" - solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml deleted file mode 100644 index 62b93679..00000000 --- a/Gopkg.toml +++ /dev/null @@ -1,10 +0,0 @@ -# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html -# for detailed Gopkg.toml documentation. - -[[constraint]] - name = "github.com/opentracing/opentracing-go" - version = "1.0.2" - -[prune] - go-tests = true - unused-packages = true diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..fd80b0f2 --- /dev/null +++ b/go.mod @@ -0,0 +1,6 @@ +module github.com/graph-gophers/graphql-go + +require ( + github.com/opentracing/opentracing-go v1.0.2 + golang.org/x/net v0.0.0-20181220203305-927f97764cc3 +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..e55f3c23 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= From 0f3ab7cef6de5622ecdf4f615fd751d5508644f8 Mon Sep 17 00:00:00 2001 From: fadi-alkatut <44017676+fadi-alkatut@users.noreply.github.com> Date: Tue, 8 Jan 2019 14:05:06 +1100 Subject: [PATCH 50/69] Update schema.go Validate types are including all fields from implemented interface(s) --- internal/schema/schema.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 569b26b2..68fedcf9 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -291,6 +291,11 @@ func (s *Schema) Parse(schemaString string, useStringDescriptions bool) error { if !ok { return errors.Errorf("type %q is not an interface", intfName) } + for _, f := range intf.Fields.Names() { + if obj.Fields.Get(f) == nil { + return errors.Errorf("interface %q expects field %q but %q does not provide it", intfName, f, obj.Name) + } + } obj.Interfaces[i] = intf intf.PossibleTypes = append(intf.PossibleTypes, obj) } From 403121a60a2da31906b405de2af281ef8545fde1 Mon Sep 17 00:00:00 2001 From: fadi-alkatut <44017676+fadi-alkatut@users.noreply.github.com> Date: Tue, 8 Jan 2019 14:07:08 +1100 Subject: [PATCH 51/69] test interface validation Validate types are including all fields from implemented interface(s) --- internal/schema/schema_internal_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/schema/schema_internal_test.go b/internal/schema/schema_internal_test.go index d652f5d5..b343a463 100644 --- a/internal/schema/schema_internal_test.go +++ b/internal/schema/schema_internal_test.go @@ -49,6 +49,10 @@ func TestParseObjectDef(t *testing.T) { description: "Parses type inheriting single interface", definition: "Hello implements World { field: String }", expected: &Object{Name: "Hello", interfaceNames: []string{"World"}}, + }, { + description: "Parses type Welcome that implements interface Greeting without providing required fields", + definition: "interface Greeting { message: String! } type Welcome implements Greeting {}", + err: errors.Errorf(`interface "Greeting" expects field "message" but "Welcome" does not provide it`), }, { description: "Parses type inheriting multiple interfaces", definition: "Hello implements Wo & rld { field: String }", From f51ee68fb96d85a1184d37e2fd729df2cf9912f2 Mon Sep 17 00:00:00 2001 From: fadi-alkatut <44017676+fadi-alkatut@users.noreply.github.com> Date: Tue, 8 Jan 2019 14:21:37 +1100 Subject: [PATCH 52/69] Update schema_internal_test.go Validate types are including all fields from implemented interface(s) --- internal/schema/schema_internal_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/schema/schema_internal_test.go b/internal/schema/schema_internal_test.go index b343a463..40c0d4a9 100644 --- a/internal/schema/schema_internal_test.go +++ b/internal/schema/schema_internal_test.go @@ -51,8 +51,8 @@ func TestParseObjectDef(t *testing.T) { expected: &Object{Name: "Hello", interfaceNames: []string{"World"}}, }, { description: "Parses type Welcome that implements interface Greeting without providing required fields", - definition: "interface Greeting { message: String! } type Welcome implements Greeting {}", - err: errors.Errorf(`interface "Greeting" expects field "message" but "Welcome" does not provide it`), + definition: "Hello implements World { }", + err: errors.Errorf(`interface "World" expects field "field" but "Hello" does not provide it`), }, { description: "Parses type inheriting multiple interfaces", definition: "Hello implements Wo & rld { field: String }", From 81cbbab69d7b332e3432324788c3428c07ff8d7d Mon Sep 17 00:00:00 2001 From: fadi-alkatut <44017676+fadi-alkatut@users.noreply.github.com> Date: Tue, 8 Jan 2019 14:25:15 +1100 Subject: [PATCH 53/69] Update schema_internal_test.go --- internal/schema/schema_internal_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/schema/schema_internal_test.go b/internal/schema/schema_internal_test.go index 40c0d4a9..d652f5d5 100644 --- a/internal/schema/schema_internal_test.go +++ b/internal/schema/schema_internal_test.go @@ -49,10 +49,6 @@ func TestParseObjectDef(t *testing.T) { description: "Parses type inheriting single interface", definition: "Hello implements World { field: String }", expected: &Object{Name: "Hello", interfaceNames: []string{"World"}}, - }, { - description: "Parses type Welcome that implements interface Greeting without providing required fields", - definition: "Hello implements World { }", - err: errors.Errorf(`interface "World" expects field "field" but "Hello" does not provide it`), }, { description: "Parses type inheriting multiple interfaces", definition: "Hello implements Wo & rld { field: String }", From 25dda10f4aa8298ba7bc36bf7134d3dd6ff98f10 Mon Sep 17 00:00:00 2001 From: fadi-alkatut <44017676+fadi-alkatut@users.noreply.github.com> Date: Tue, 8 Jan 2019 15:03:14 +1100 Subject: [PATCH 54/69] Update schema_test.go --- internal/schema/schema_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index 5ee5156d..eb9b431f 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -89,3 +89,25 @@ func TestParse(t *testing.T) { }) } } + +func TestInvalidInterfaceImpl(t *testing.T) { + var tests = []parseTestCase{{ + description: "Parses type Welcome that implements interface Greeting without providing required fields", + sdl: "interface Greeting { message: String! } type Welcome implements Greeting {}", + err: errors.Errorf(`interface "Greeting" expects field "message" but "Welcome" does not provide it`), + }} + + setup := func(t *testing.T) *schema.Schema { + t.Helper() + return schema.New() + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + schema := setup(t) + err := schema.Parse(test.sdl, false) + if err == nil || err.Error() != test.err.Error() { + t.Fatal(err) + } + }) + } +} From 27cde2c87abe157abfeae54b3f01886b74504c78 Mon Sep 17 00:00:00 2001 From: fadi-alkatut <44017676+fadi-alkatut@users.noreply.github.com> Date: Tue, 8 Jan 2019 15:09:36 +1100 Subject: [PATCH 55/69] Update schema_test.go --- internal/schema/schema_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index eb9b431f..e1239c25 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -3,6 +3,7 @@ package schema_test import ( "testing" + "github.com/graph-gophers/graphql-go/errors" "github.com/graph-gophers/graphql-go/internal/schema" ) From 6bc8514356e6d147dcd4e50de83a92366fdb7cc3 Mon Sep 17 00:00:00 2001 From: Salman Ahmad Date: Thu, 17 Jan 2019 08:13:27 -0500 Subject: [PATCH 56/69] reverted changes in gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index cf07dd5e..7b3bcd13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ /internal/validation/testdata/graphql-js /internal/validation/testdata/node_modules /vendor -.DS_Store -.idea/ -.vscode/ From e3e3046008262060b55c2650e670fe643db8b586 Mon Sep 17 00:00:00 2001 From: Salman Ahmad Date: Mon, 4 Feb 2019 17:14:11 -0500 Subject: [PATCH 57/69] refactored code and documentation as per PR feedback --- README.md | 8 ++++---- example/social/server/server.go | 1 - example/social/social.go | 6 +++--- graphql.go | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a0b6d58a..01038bfa 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ opts := []graphql.SchemaOpt{graphql.UseFieldResolvers()} schema := graphql.MustParseSchema(s, &query{}, opts...) ``` -When using `UseFieldResolvers`, a field will be used *only* when: -- there is no method -- it does not implement an interface -- it does not have arguments +When using `UseFieldResolvers` schema option, a struct field will be used *only* when: +- there is no method for a struct field +- a struct field does not implement an interface method +- a struct field does not have arguments The method has up to two arguments: diff --git a/example/social/server/server.go b/example/social/server/server.go index 21b6f384..6bfde72b 100644 --- a/example/social/server/server.go +++ b/example/social/server/server.go @@ -10,7 +10,6 @@ import ( ) func main() { - opts := []graphql.SchemaOpt{graphql.UseFieldResolvers(), graphql.MaxParallelism(20)} schema := graphql.MustParseSchema(social.Schema, &social.Resolver{}, opts...) diff --git a/example/social/social.go b/example/social/social.go index 83f26ad6..67774207 100644 --- a/example/social/social.go +++ b/example/social/social.go @@ -175,15 +175,15 @@ func init() { type Resolver struct{} func (r *Resolver) Admin(ctx context.Context, args struct { - Id string + ID string Role string }) (admin, error) { - if usr, ok := usersMap[args.Id]; ok { + if usr, ok := usersMap[args.ID]; ok { if usr.RoleField == args.Role { return *usr, nil } } - err := fmt.Errorf("user with id=%s and role=%s does not exist", args.Id, args.Role) + err := fmt.Errorf("user with id=%s and role=%s does not exist", args.ID, args.Role) return user{}, err } diff --git a/graphql.go b/graphql.go index 12b17dfc..f3fe32eb 100644 --- a/graphql.go +++ b/graphql.go @@ -83,7 +83,7 @@ func UseStringDescriptions() SchemaOpt { } } -// Specifies whether to use struct field resolvers +// UseFieldResolvers specifies whether to use struct field resolvers func UseFieldResolvers() SchemaOpt { return func(s *Schema) { s.schema.UseFieldResolvers = true From fde50bb453921a1eb614886f719a7bd52167cb3c Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Thu, 14 Feb 2019 17:42:47 -0500 Subject: [PATCH 58/69] Add DisableIntrospection SchemaOpt Allows the server to disable schema introspection. --- graphql.go | 15 ++- graphql_test.go | 158 +++++++++++++++++++++++++++++ internal/exec/selected/selected.go | 73 +++++++------ 3 files changed, 210 insertions(+), 36 deletions(-) diff --git a/graphql.go b/graphql.go index f3fe32eb..be0e1c41 100644 --- a/graphql.go +++ b/graphql.go @@ -68,6 +68,7 @@ type Schema struct { validationTracer trace.ValidationTracer logger log.Logger useStringDescriptions bool + disableIntrospection bool } // SchemaOpt is an option to pass to ParseSchema or MustParseSchema. @@ -125,6 +126,13 @@ func Logger(logger log.Logger) SchemaOpt { } } +// DisableIntrospection disables introspection queries. +func DisableIntrospection() SchemaOpt { + return func(s *Schema) { + s.disableIntrospection = true + } +} + // Response represents a typical response of a GraphQL server. It may be encoded to JSON directly or // it may be further processed to a custom response type, for example to include custom error data. // Errors are intentionally serialized first based on the advice in https://github.com/facebook/graphql/commit/7b40390d48680b15cb93e02d46ac5eb249689876#diff-757cea6edf0288677a9eea4cfc801d87R107 @@ -184,9 +192,10 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str r := &exec.Request{ Request: selected.Request{ - Doc: doc, - Vars: variables, - Schema: s.schema, + Doc: doc, + Vars: variables, + Schema: s.schema, + DisableIntrospection: s.disableIntrospection, }, Limiter: make(chan struct{}, s.maxParallelism), Tracer: s.tracer, diff --git a/graphql_test.go b/graphql_test.go index 201ce6dc..eb4bb974 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -1905,6 +1905,164 @@ func TestIntrospection(t *testing.T) { }) } +var starwarsSchemaNoIntrospection = graphql.MustParseSchema(starwars.Schema, &starwars.Resolver{}, []graphql.SchemaOpt{graphql.DisableIntrospection()}...) + +func TestIntrospectionDisableIntrospection(t *testing.T) { + gqltesting.RunTests(t, []*gqltesting.Test{ + { + Schema: starwarsSchemaNoIntrospection, + Query: ` + { + __schema { + types { + name + } + } + } + `, + ExpectedResult: ` + { + } + `, + }, + + { + Schema: starwarsSchemaNoIntrospection, + Query: ` + { + __schema { + queryType { + name + } + } + } + `, + ExpectedResult: ` + { + } + `, + }, + + { + Schema: starwarsSchemaNoIntrospection, + Query: ` + { + a: __type(name: "Droid") { + name + kind + interfaces { + name + } + possibleTypes { + name + } + }, + b: __type(name: "Character") { + name + kind + interfaces { + name + } + possibleTypes { + name + } + } + c: __type(name: "SearchResult") { + name + kind + interfaces { + name + } + possibleTypes { + name + } + } + } + `, + ExpectedResult: ` + { + } + `, + }, + + { + Schema: starwarsSchemaNoIntrospection, + Query: ` + { + __type(name: "Droid") { + name + fields { + name + args { + name + type { + name + } + defaultValue + } + type { + name + kind + } + } + } + } + `, + ExpectedResult: ` + { + } + `, + }, + + { + Schema: starwarsSchemaNoIntrospection, + Query: ` + { + __type(name: "Episode") { + enumValues { + name + } + } + } + `, + ExpectedResult: ` + { + } + `, + }, + + { + Schema: starwarsSchemaNoIntrospection, + Query: ` + { + __schema { + directives { + name + description + locations + args { + name + description + type { + kind + ofType { + kind + name + } + } + } + } + } + } + `, + ExpectedResult: ` + { + } + `, + }, + }) +} + func TestMutationOrder(t *testing.T) { gqltesting.RunTests(t, []*gqltesting.Test{ { diff --git a/internal/exec/selected/selected.go b/internal/exec/selected/selected.go index 2a957f55..78ab888a 100644 --- a/internal/exec/selected/selected.go +++ b/internal/exec/selected/selected.go @@ -15,11 +15,12 @@ import ( ) type Request struct { - Schema *schema.Schema - Doc *query.Document - Vars map[string]interface{} - Mu sync.Mutex - Errs []*errors.QueryError + Schema *schema.Schema + Doc *query.Document + Vars map[string]interface{} + Mu sync.Mutex + Errs []*errors.QueryError + DisableIntrospection bool } func (r *Request) AddError(err *errors.QueryError) { @@ -80,40 +81,46 @@ func applySelectionSet(r *Request, e *resolvable.Object, sels []query.Selection) switch field.Name.Name { case "__typename": - flattenedSels = append(flattenedSels, &TypenameField{ - Object: *e, - Alias: field.Alias.Name, - }) + if !r.DisableIntrospection { + flattenedSels = append(flattenedSels, &TypenameField{ + Object: *e, + Alias: field.Alias.Name, + }) + } case "__schema": - flattenedSels = append(flattenedSels, &SchemaField{ - Field: resolvable.MetaFieldSchema, - Alias: field.Alias.Name, - Sels: applySelectionSet(r, resolvable.MetaSchema, field.Selections), - Async: true, - FixedResult: reflect.ValueOf(introspection.WrapSchema(r.Schema)), - }) + if !r.DisableIntrospection { + flattenedSels = append(flattenedSels, &SchemaField{ + Field: resolvable.MetaFieldSchema, + Alias: field.Alias.Name, + Sels: applySelectionSet(r, resolvable.MetaSchema, field.Selections), + Async: true, + FixedResult: reflect.ValueOf(introspection.WrapSchema(r.Schema)), + }) + } case "__type": - p := packer.ValuePacker{ValueType: reflect.TypeOf("")} - v, err := p.Pack(field.Arguments.MustGet("name").Value(r.Vars)) - if err != nil { - r.AddError(errors.Errorf("%s", err)) - return nil - } + if !r.DisableIntrospection { + p := packer.ValuePacker{ValueType: reflect.TypeOf("")} + v, err := p.Pack(field.Arguments.MustGet("name").Value(r.Vars)) + if err != nil { + r.AddError(errors.Errorf("%s", err)) + return nil + } - t, ok := r.Schema.Types[v.String()] - if !ok { - return nil - } + t, ok := r.Schema.Types[v.String()] + if !ok { + return nil + } - flattenedSels = append(flattenedSels, &SchemaField{ - Field: resolvable.MetaFieldType, - Alias: field.Alias.Name, - Sels: applySelectionSet(r, resolvable.MetaType, field.Selections), - Async: true, - FixedResult: reflect.ValueOf(introspection.WrapType(t)), - }) + flattenedSels = append(flattenedSels, &SchemaField{ + Field: resolvable.MetaFieldType, + Alias: field.Alias.Name, + Sels: applySelectionSet(r, resolvable.MetaType, field.Selections), + Async: true, + FixedResult: reflect.ValueOf(introspection.WrapType(t)), + }) + } default: fe := e.Fields[field.Name.Name] From 36edbf2cfbd5a655ad49deaf14b6efd189d7dd42 Mon Sep 17 00:00:00 2001 From: Michal Jemala Date: Fri, 5 Apr 2019 13:34:31 +0200 Subject: [PATCH 59/69] Fix parsing of descriptions --- internal/common/lexer.go | 73 +++++---- internal/schema/schema_test.go | 272 +++++++++++++++++++++------------ 2 files changed, 206 insertions(+), 139 deletions(-) diff --git a/internal/common/lexer.go b/internal/common/lexer.go index 8b3176c9..9cc7e547 100644 --- a/internal/common/lexer.go +++ b/internal/common/lexer.go @@ -1,6 +1,7 @@ package common import ( + "bytes" "fmt" "strconv" "strings" @@ -14,7 +15,7 @@ type syntaxError string type Lexer struct { sc *scanner.Scanner next rune - descComment string + comment bytes.Buffer useStringDescriptions bool } @@ -58,9 +59,7 @@ func (l *Lexer) Peek() rune { // The description is available from `DescComment()`, and will be reset every time `ConsumeWhitespace()` is // executed unless l.useStringDescriptions is set. func (l *Lexer) ConsumeWhitespace() { - if !l.useStringDescriptions { - l.descComment = "" - } + l.comment.Reset() for { l.next = l.sc.Scan() @@ -79,7 +78,6 @@ func (l *Lexer) ConsumeWhitespace() { // A comment can contain any Unicode code point except `LineTerminator` so a comment always // consists of all code points starting with the '#' character up to but not including the // line terminator. - l.consumeComment() continue } @@ -95,22 +93,20 @@ func (l *Lexer) ConsumeWhitespace() { // If a description is found, consume any following comments as well // // http://facebook.github.io/graphql/June2018/#sec-Descriptions -func (l *Lexer) consumeDescription() bool { +func (l *Lexer) consumeDescription() string { // If the next token is not a string, we don't consume it - if l.next == scanner.String { - // a triple quote string is an empty "string" followed by an open quote due to the way the parser treats strings as one token - l.descComment = "" - tokenText := l.sc.TokenText() - if l.sc.Peek() == '"' { - // Consume the third quote - l.next = l.sc.Next() - l.consumeTripleQuoteComment() - } else { - l.consumeStringComment(tokenText) - } - return true + if l.next != scanner.String { + return "" + } + // Triple quote string is an empty "string" followed by an open quote due to the way the parser treats strings as one token + var desc string + if l.sc.Peek() == '"' { + desc = l.consumeTripleQuoteComment() + } else { + desc = l.consumeStringComment() } - return false + l.ConsumeWhitespace() + return desc } func (l *Lexer) ConsumeIdent() string { @@ -147,12 +143,12 @@ func (l *Lexer) ConsumeToken(expected rune) { } func (l *Lexer) DescComment() string { + comment := l.comment.String() + desc := l.consumeDescription() if l.useStringDescriptions { - if l.consumeDescription() { - l.ConsumeWhitespace() - } + return desc } - return l.descComment + return comment } func (l *Lexer) SyntaxError(message string) { @@ -166,12 +162,13 @@ func (l *Lexer) Location() errors.Location { } } -func (l *Lexer) consumeTripleQuoteComment() { +func (l *Lexer) consumeTripleQuoteComment() string { + l.next = l.sc.Next() if l.next != '"' { panic("consumeTripleQuoteComment used in wrong context: no third quote?") } - var comment string + var buf bytes.Buffer var numQuotes int for { l.next = l.sc.Next() @@ -180,24 +177,27 @@ func (l *Lexer) consumeTripleQuoteComment() { } else { numQuotes = 0 } - comment += string(l.next) + buf.WriteRune(l.next) if numQuotes == 3 || l.next == scanner.EOF { break } } - l.descComment += strings.TrimSpace(comment[:len(comment)-numQuotes]) + val := buf.String() + val = val[:len(val)-numQuotes] + val = strings.TrimSpace(val) + return val } -func (l *Lexer) consumeStringComment(str string) { - value, err := strconv.Unquote(str) +func (l *Lexer) consumeStringComment() string { + val, err := strconv.Unquote(l.sc.TokenText()) if err != nil { panic(err) } - l.descComment += value + return val } // consumeComment consumes all characters from `#` to the first encountered line terminator. -// The characters are appended to `l.descComment`. +// The characters are appended to `l.comment`. func (l *Lexer) consumeComment() { if l.next != '#' { panic("consumeComment used in wrong context") @@ -208,9 +208,8 @@ func (l *Lexer) consumeComment() { l.sc.Next() } - if l.descComment != "" && !l.useStringDescriptions { - // TODO: use a bytes.Buffer or strings.Builder instead of this. - l.descComment += "\n" + if l.comment.Len() > 0 { + l.comment.WriteRune('\n') } for { @@ -218,10 +217,6 @@ func (l *Lexer) consumeComment() { if next == '\r' || next == '\n' || next == scanner.EOF { break } - - if !l.useStringDescriptions { - // TODO: use a bytes.Buffer or strings.Build instead of this. - l.descComment += string(next) - } + l.comment.WriteRune(next) } } diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index e1239c25..0352a9e5 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -1,113 +1,185 @@ package schema_test import ( + "fmt" "testing" - "github.com/graph-gophers/graphql-go/errors" "github.com/graph-gophers/graphql-go/internal/schema" ) -type parseTestCase struct { - description string - sdl string - expected *schema.Schema - err error -} - -var parseTests = []parseTestCase{{ - description: "Parses interface definition", - sdl: "interface Greeting { message: String! }", - expected: &schema.Schema{ - Types: map[string]schema.NamedType{ - "Greeting": &schema.Interface{ - Name: "Greeting", - Fields: []*schema.Field{{Name: "message"}}, - }}, - }}, { - description: "Parses type with description string", - sdl: ` - "Single line description." - type Type { - field: String - }`, - expected: &schema.Schema{ - Types: map[string]schema.NamedType{ - "Type": &schema.Object{ - Name: "Type", - Desc: "Single line description.", - }}, - }}, { - description: "Parses type with multi-line description string", - sdl: ` - """ - Multi-line description. - """ - type Type { - field: String - }`, - expected: &schema.Schema{ - Types: map[string]schema.NamedType{ - "Type": &schema.Object{ - Name: "Type", - Desc: "Multi-line description.", - }}, - }}, { - description: "Parses type with multi-line description and ignores comments", - sdl: ` - """ - Multi-line description with ignored comments. - """ - # This comment should be ignored. - type Type { - field: String - }`, - expected: &schema.Schema{ - Types: map[string]schema.NamedType{ - "Type": &schema.Object{ - Name: "Type", - Desc: "Multi-line description with ignored comments.", - }}, - }}, -} - func TestParse(t *testing.T) { - setup := func(t *testing.T) *schema.Schema { - t.Helper() - return schema.New() - } - - for _, test := range parseTests { - t.Run(test.description, func(t *testing.T) { - t.Skip("TODO: add support for descriptions") - schema := setup(t) - - err := schema.Parse(test.sdl, false) - if err != nil { - t.Fatal(err) + for _, test := range []struct { + name string + sdl string + useStringDescriptions bool + validateError func(err error) error + validateSchema func(s *schema.Schema) error + }{ + { + name: "Parses interface definition", + sdl: "interface Greeting { message: String! }", + validateSchema: func(s *schema.Schema) error { + const typeName = "Greeting" + typ, ok := s.Types[typeName].(*schema.Interface) + if !ok { + return fmt.Errorf("interface %q not found", typeName) + } + if want, have := 1, len(typ.Fields); want != have { + return fmt.Errorf("invalid number of fields: want %d, have %d", want, have) + } + const fieldName = "message" + if typ.Fields[0].Name != fieldName { + return fmt.Errorf("field %q not found", fieldName) + } + return nil + }, + }, + { + name: "Parses implementing type without providing required fields", + sdl: ` + interface Greeting { + message: String! + } + type Welcome implements Greeting { + }`, + validateError: func(err error) error { + if err == nil { + return fmt.Errorf("want error, have ") + } + if want, have := `graphql: interface "Greeting" expects field "message" but "Welcome" does not provide it`, err.Error(); want != have { + return fmt.Errorf("unexpected error: want %q, have %q", want, have) + } + return nil + }, + }, + { + name: "Parses type with description string", + sdl: ` + "Single line description." + type Type { + field: String + }`, + useStringDescriptions: true, + validateSchema: func(s *schema.Schema) error { + const typeName = "Type" + typ, ok := s.Types[typeName].(*schema.Object) + if !ok { + return fmt.Errorf("type %q not found", typeName) + } + if want, have := "Single line description.", typ.Description(); want != have { + return fmt.Errorf("invalid description: want %q, have %q", want, have) + } + return nil + }, + }, + { + name: "Parses type with multi-line description string", + sdl: ` + """ + Multi-line description. + """ + type Type { + field: String + }`, + useStringDescriptions: true, + validateSchema: func(s *schema.Schema) error { + const typeName = "Type" + typ, ok := s.Types[typeName].(*schema.Object) + if !ok { + return fmt.Errorf("type %q not found", typeName) + } + if want, have := "Multi-line description.", typ.Description(); want != have { + return fmt.Errorf("invalid description: want %q, have %q", want, have) + } + return nil + }, + }, + { + name: "Parses type with multi-line description and ignores comments", + sdl: ` + """ + Multi-line description with ignored comments. + """ + # This comment should be ignored. + type Type { + field: String + }`, + useStringDescriptions: true, + validateSchema: func(s *schema.Schema) error { + const typeName = "Type" + typ, ok := s.Types[typeName].(*schema.Object) + if !ok { + return fmt.Errorf("type %q not found", typeName) + } + if want, have := "Multi-line description with ignored comments.", typ.Description(); want != have { + return fmt.Errorf("invalid description: want %q, have %q", want, have) + } + return nil + }, + }, + { + name: "Description is correctly parsed for non-described types", + sdl: ` + "Some description." + scalar MyInt + type Type { + field: String + }`, + useStringDescriptions: true, + validateSchema: func(s *schema.Schema) error { + typ, ok := s.Types["Type"] + if !ok { + return fmt.Errorf("type %q not found", "Type") + } + if want, have := "", typ.Description(); want != have { + return fmt.Errorf("description does not match: want %q, have %q ", want, have) + } + return nil + }, + }, + { + name: "Multi-line comment is correctly parsed", + sdl: ` + # Multi-line + # comment. + " This description should be ignored. " + scalar MyInt + type Type { + field: String + }`, + validateSchema: func(s *schema.Schema) error { + typ, ok := s.Types["MyInt"] + if !ok { + return fmt.Errorf("scalar %q not found", "MyInt") + } + if want, have := "Multi-line\ncomment.", typ.Description(); want != have { + return fmt.Errorf("description does not match: want %q, have %q ", want, have) + } + typ, ok = s.Types["Type"] + if !ok { + return fmt.Errorf("type %q not found", "Type") + } + if want, have := "", typ.Description(); want != have { + return fmt.Errorf("description does not match: want %q, have %q ", want, have) + } + return nil + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + s := schema.New() + if err := s.Parse(test.sdl, test.useStringDescriptions); err != nil { + if test.validateError == nil { + t.Fatal(err) + } + if err := test.validateError(err); err != nil { + t.Fatal(err) + } } - - // TODO: verify schema is the same as expected. - }) - } -} - -func TestInvalidInterfaceImpl(t *testing.T) { - var tests = []parseTestCase{{ - description: "Parses type Welcome that implements interface Greeting without providing required fields", - sdl: "interface Greeting { message: String! } type Welcome implements Greeting {}", - err: errors.Errorf(`interface "Greeting" expects field "message" but "Welcome" does not provide it`), - }} - - setup := func(t *testing.T) *schema.Schema { - t.Helper() - return schema.New() - } - for _, test := range tests { - t.Run(test.description, func(t *testing.T) { - schema := setup(t) - err := schema.Parse(test.sdl, false) - if err == nil || err.Error() != test.err.Error() { - t.Fatal(err) + if test.validateSchema != nil { + if err := test.validateSchema(s); err != nil { + t.Fatal(err) + } } }) } From eddaba1a09fe555c5cd201d335c708dfe0e6cc21 Mon Sep 17 00:00:00 2001 From: David Ackroyd Date: Fri, 26 Apr 2019 21:12:38 +1000 Subject: [PATCH 60/69] Resolve schema parsing data races Moving package globals to per-schema objects, resolving data races from parsing multiple schemas in parallel. While this has limited likelihood in production code (since typically just 1 schema would be used), tests are a different story --- graphql_test.go | 28 ++++++++++ internal/exec/exec.go | 32 ++++++------ internal/exec/resolvable/meta.go | 72 +++++++++++++++----------- internal/exec/resolvable/resolvable.go | 2 + internal/exec/selected/selected.go | 30 +++++------ internal/exec/subscribe.go | 4 +- internal/schema/meta.go | 17 ++++-- internal/schema/schema.go | 5 +- 8 files changed, 121 insertions(+), 69 deletions(-) diff --git a/graphql_test.go b/graphql_test.go index eb4bb974..9308f0e6 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -164,6 +164,8 @@ func (r *discussPlanResolver) DismissVader(ctx context.Context) (string, error) } func TestHelloWorld(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -212,6 +214,8 @@ func TestHelloWorld(t *testing.T) { } func TestHelloSnake(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -260,6 +264,8 @@ func TestHelloSnake(t *testing.T) { } func TestHelloSnakeArguments(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -360,6 +366,8 @@ func (r *testNilInterfaceResolver) C() (interface{ Z() int32 }, error) { } func TestNilInterface(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -403,6 +411,8 @@ func TestNilInterface(t *testing.T) { } func TestErrorPropagationInLists(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -631,6 +641,8 @@ func TestErrorPropagationInLists(t *testing.T) { } func TestErrorWithExtensions(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -670,6 +682,8 @@ func TestErrorWithExtensions(t *testing.T) { } func TestErrorWithNoExtensions(t *testing.T) { + t.Parallel() + err := errors.New("I find your lack of faith disturbing") gqltesting.RunTests(t, []*gqltesting.Test{ @@ -1074,6 +1088,8 @@ func (r *testDeprecatedDirectiveResolver) C() int32 { } func TestDeprecatedDirective(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -2064,6 +2080,8 @@ func TestIntrospectionDisableIntrospection(t *testing.T) { } func TestMutationOrder(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -2111,6 +2129,8 @@ func TestMutationOrder(t *testing.T) { } func TestTime(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` @@ -2150,6 +2170,8 @@ func (r *resolverWithUnexportedMethod) changeTheNumber(args struct{ NewNumber in } func TestUnexportedMethod(t *testing.T) { + t.Parallel() + _, err := graphql.ParseSchema(` schema { mutation: Mutation @@ -2171,6 +2193,8 @@ func (r *resolverWithUnexportedField) ChangeTheNumber(args struct{ newNumber int } func TestUnexportedField(t *testing.T) { + t.Parallel() + _, err := graphql.ParseSchema(` schema { mutation: Mutation @@ -2323,6 +2347,8 @@ func (r *inputResolver) ID(args struct{ Value graphql.ID }) graphql.ID { } func TestInput(t *testing.T) { + t.Parallel() + coercionSchema := graphql.MustParseSchema(` schema { query: Query @@ -2511,6 +2537,8 @@ func (r *childResolver) NilChild() *childResolver { } func TestErrorPropagation(t *testing.T) { + t.Parallel() + gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: graphql.MustParseSchema(` diff --git a/internal/exec/exec.go b/internal/exec/exec.go index f6ec84fb..fd8cd33e 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -45,7 +45,7 @@ func (r *Request) Execute(ctx context.Context, s *resolvable.Schema, op *query.O func() { defer r.handlePanic(ctx) sels := selected.ApplyOperation(&r.Request, s, op) - r.execSelections(ctx, sels, nil, s.Resolver, &out, op.Type == query.Mutation) + r.execSelections(ctx, sels, nil, s, s.Resolver, &out, op.Type == query.Mutation) }() if err := ctx.Err(); err != nil { @@ -66,11 +66,11 @@ func resolvedToNull(b *bytes.Buffer) bool { return bytes.Equal(b.Bytes(), []byte("null")) } -func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, serially bool) { +func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, s *resolvable.Schema, resolver reflect.Value, out *bytes.Buffer, serially bool) { async := !serially && selected.HasAsyncSel(sels) var fields []*fieldToExec - collectFieldsToResolve(sels, resolver, &fields, make(map[string]*fieldToExec)) + collectFieldsToResolve(sels, s, resolver, &fields, make(map[string]*fieldToExec)) if async { var wg sync.WaitGroup @@ -80,14 +80,14 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, defer wg.Done() defer r.handlePanic(ctx) f.out = new(bytes.Buffer) - execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, true) + execFieldSelection(ctx, r, s, f, &pathSegment{path, f.field.Alias}, true) }(f) } wg.Wait() } else { for _, f := range fields { f.out = new(bytes.Buffer) - execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, true) + execFieldSelection(ctx, r, s, f, &pathSegment{path, f.field.Alias}, true) } } @@ -114,7 +114,7 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, out.WriteByte('}') } -func collectFieldsToResolve(sels []selected.Selection, resolver reflect.Value, fields *[]*fieldToExec, fieldByAlias map[string]*fieldToExec) { +func collectFieldsToResolve(sels []selected.Selection, s *resolvable.Schema, resolver reflect.Value, fields *[]*fieldToExec, fieldByAlias map[string]*fieldToExec) { for _, sel := range sels { switch sel := sel.(type) { case *selected.SchemaField: @@ -128,7 +128,7 @@ func collectFieldsToResolve(sels []selected.Selection, resolver reflect.Value, f case *selected.TypenameField: sf := &selected.SchemaField{ - Field: resolvable.MetaFieldTypename, + Field: s.Meta.FieldTypename, Alias: sel.Alias, FixedResult: reflect.ValueOf(typeOf(sel, resolver)), } @@ -139,7 +139,7 @@ func collectFieldsToResolve(sels []selected.Selection, resolver reflect.Value, f if !out[1].Bool() { continue } - collectFieldsToResolve(sel.Sels, out[0], fields, fieldByAlias) + collectFieldsToResolve(sel.Sels, s, out[0], fields, fieldByAlias) default: panic("unreachable") @@ -160,7 +160,7 @@ func typeOf(tf *selected.TypenameField, resolver reflect.Value) string { return "" } -func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *pathSegment, applyLimiter bool) { +func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f *fieldToExec, path *pathSegment, applyLimiter bool) { if applyLimiter { r.Limiter <- struct{}{} } @@ -234,10 +234,10 @@ func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *p return } - r.execSelectionSet(traceCtx, f.sels, f.field.Type, path, result, f.out) + r.execSelectionSet(traceCtx, f.sels, f.field.Type, path, s, result, f.out) } -func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selection, typ common.Type, path *pathSegment, resolver reflect.Value, out *bytes.Buffer) { +func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selection, typ common.Type, path *pathSegment, s *resolvable.Schema, resolver reflect.Value, out *bytes.Buffer) { t, nonNull := unwrapNonNull(typ) switch t := t.(type) { case *schema.Object, *schema.Interface, *schema.Union: @@ -255,7 +255,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio return } - r.execSelections(ctx, sels, path, resolver, out, false) + r.execSelections(ctx, sels, path, s, resolver, out, false) return } @@ -269,7 +269,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio switch t := t.(type) { case *common.List: - r.execList(ctx, sels, t, path, resolver, out) + r.execList(ctx, sels, t, path, s, resolver, out) case *schema.Scalar: v := resolver.Interface() @@ -293,7 +293,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio } } -func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ *common.List, path *pathSegment, resolver reflect.Value, out *bytes.Buffer) { +func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ *common.List, path *pathSegment, s *resolvable.Schema, resolver reflect.Value, out *bytes.Buffer) { l := resolver.Len() entryouts := make([]bytes.Buffer, l) @@ -304,13 +304,13 @@ func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ * go func(i int) { defer wg.Done() defer r.handlePanic(ctx) - r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, resolver.Index(i), &entryouts[i]) + r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, s, resolver.Index(i), &entryouts[i]) }(i) } wg.Wait() } else { for i := 0; i < l; i++ { - r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, resolver.Index(i), &entryouts[i]) + r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, s, resolver.Index(i), &entryouts[i]) } } diff --git a/internal/exec/resolvable/meta.go b/internal/exec/resolvable/meta.go index 826c8234..e9707516 100644 --- a/internal/exec/resolvable/meta.go +++ b/internal/exec/resolvable/meta.go @@ -9,21 +9,27 @@ import ( "github.com/graph-gophers/graphql-go/introspection" ) -var MetaSchema *Object -var MetaType *Object +// Meta defines the details of the metadata schema for introspection. +type Meta struct { + FieldSchema Field + FieldType Field + FieldTypename Field + Schema *Object + Type *Object +} -func init() { +func newMeta(s *schema.Schema) *Meta { var err error - b := newBuilder(schema.Meta) + b := newBuilder(s) - metaSchema := schema.Meta.Types["__Schema"].(*schema.Object) - MetaSchema, err = b.makeObjectExec(metaSchema.Name, metaSchema.Fields, nil, false, reflect.TypeOf(&introspection.Schema{})) + metaSchema := s.Types["__Schema"].(*schema.Object) + so, err := b.makeObjectExec(metaSchema.Name, metaSchema.Fields, nil, false, reflect.TypeOf(&introspection.Schema{})) if err != nil { panic(err) } - metaType := schema.Meta.Types["__Type"].(*schema.Object) - MetaType, err = b.makeObjectExec(metaType.Name, metaType.Fields, nil, false, reflect.TypeOf(&introspection.Type{})) + metaType := s.Types["__Type"].(*schema.Object) + t, err := b.makeObjectExec(metaType.Name, metaType.Fields, nil, false, reflect.TypeOf(&introspection.Type{})) if err != nil { panic(err) } @@ -31,28 +37,36 @@ func init() { if err := b.finish(); err != nil { panic(err) } -} -var MetaFieldTypename = Field{ - Field: schema.Field{ - Name: "__typename", - Type: &common.NonNull{OfType: schema.Meta.Types["String"]}, - }, - TraceLabel: fmt.Sprintf("GraphQL field: __typename"), -} + fieldTypename := Field{ + Field: schema.Field{ + Name: "__typename", + Type: &common.NonNull{OfType: s.Types["String"]}, + }, + TraceLabel: fmt.Sprintf("GraphQL field: __typename"), + } -var MetaFieldSchema = Field{ - Field: schema.Field{ - Name: "__schema", - Type: schema.Meta.Types["__Schema"], - }, - TraceLabel: fmt.Sprintf("GraphQL field: __schema"), -} + fieldSchema := Field{ + Field: schema.Field{ + Name: "__schema", + Type: s.Types["__Schema"], + }, + TraceLabel: fmt.Sprintf("GraphQL field: __schema"), + } + + fieldType := Field{ + Field: schema.Field{ + Name: "__type", + Type: s.Types["__Type"], + }, + TraceLabel: fmt.Sprintf("GraphQL field: __type"), + } -var MetaFieldType = Field{ - Field: schema.Field{ - Name: "__type", - Type: schema.Meta.Types["__Type"], - }, - TraceLabel: fmt.Sprintf("GraphQL field: __type"), + return &Meta{ + FieldSchema: fieldSchema, + FieldTypename: fieldTypename, + FieldType: fieldType, + Schema: so, + Type: t, + } } diff --git a/internal/exec/resolvable/resolvable.go b/internal/exec/resolvable/resolvable.go index 27809230..87a2bd1f 100644 --- a/internal/exec/resolvable/resolvable.go +++ b/internal/exec/resolvable/resolvable.go @@ -12,6 +12,7 @@ import ( ) type Schema struct { + *Meta schema.Schema Query Resolvable Mutation Resolvable @@ -88,6 +89,7 @@ func ApplyResolver(s *schema.Schema, resolver interface{}) (*Schema, error) { } return &Schema{ + Meta: newMeta(s), Schema: *s, Resolver: reflect.ValueOf(resolver), Query: query, diff --git a/internal/exec/selected/selected.go b/internal/exec/selected/selected.go index 78ab888a..3075521e 100644 --- a/internal/exec/selected/selected.go +++ b/internal/exec/selected/selected.go @@ -39,7 +39,7 @@ func ApplyOperation(r *Request, s *resolvable.Schema, op *query.Operation) []Sel case query.Subscription: obj = s.Subscription.(*resolvable.Object) } - return applySelectionSet(r, obj, op.Selections) + return applySelectionSet(r, s, obj, op.Selections) } type Selection interface { @@ -70,7 +70,7 @@ func (*SchemaField) isSelection() {} func (*TypeAssertion) isSelection() {} func (*TypenameField) isSelection() {} -func applySelectionSet(r *Request, e *resolvable.Object, sels []query.Selection) (flattenedSels []Selection) { +func applySelectionSet(r *Request, s *resolvable.Schema, e *resolvable.Object, sels []query.Selection) (flattenedSels []Selection) { for _, sel := range sels { switch sel := sel.(type) { case *query.Field: @@ -91,9 +91,9 @@ func applySelectionSet(r *Request, e *resolvable.Object, sels []query.Selection) case "__schema": if !r.DisableIntrospection { flattenedSels = append(flattenedSels, &SchemaField{ - Field: resolvable.MetaFieldSchema, + Field: s.Meta.FieldSchema, Alias: field.Alias.Name, - Sels: applySelectionSet(r, resolvable.MetaSchema, field.Selections), + Sels: applySelectionSet(r, s, s.Meta.Schema, field.Selections), Async: true, FixedResult: reflect.ValueOf(introspection.WrapSchema(r.Schema)), }) @@ -114,9 +114,9 @@ func applySelectionSet(r *Request, e *resolvable.Object, sels []query.Selection) } flattenedSels = append(flattenedSels, &SchemaField{ - Field: resolvable.MetaFieldType, + Field: s.Meta.FieldType, Alias: field.Alias.Name, - Sels: applySelectionSet(r, resolvable.MetaType, field.Selections), + Sels: applySelectionSet(r, s, s.Meta.Type, field.Selections), Async: true, FixedResult: reflect.ValueOf(introspection.WrapType(t)), }) @@ -140,7 +140,7 @@ func applySelectionSet(r *Request, e *resolvable.Object, sels []query.Selection) } } - fieldSels := applyField(r, fe.ValueExec, field.Selections) + fieldSels := applyField(r, s, fe.ValueExec, field.Selections) flattenedSels = append(flattenedSels, &SchemaField{ Field: *fe, Alias: field.Alias.Name, @@ -156,14 +156,14 @@ func applySelectionSet(r *Request, e *resolvable.Object, sels []query.Selection) if skipByDirective(r, frag.Directives) { continue } - flattenedSels = append(flattenedSels, applyFragment(r, e, &frag.Fragment)...) + flattenedSels = append(flattenedSels, applyFragment(r, s, e, &frag.Fragment)...) case *query.FragmentSpread: spread := sel if skipByDirective(r, spread.Directives) { continue } - flattenedSels = append(flattenedSels, applyFragment(r, e, &r.Doc.Fragments.Get(spread.Name.Name).Fragment)...) + flattenedSels = append(flattenedSels, applyFragment(r, s, e, &r.Doc.Fragments.Get(spread.Name.Name).Fragment)...) default: panic("invalid type") @@ -172,7 +172,7 @@ func applySelectionSet(r *Request, e *resolvable.Object, sels []query.Selection) return } -func applyFragment(r *Request, e *resolvable.Object, frag *query.Fragment) []Selection { +func applyFragment(r *Request, s *resolvable.Schema, e *resolvable.Object, frag *query.Fragment) []Selection { if frag.On.Name != "" && frag.On.Name != e.Name { a, ok := e.TypeAssertions[frag.On.Name] if !ok { @@ -181,18 +181,18 @@ func applyFragment(r *Request, e *resolvable.Object, frag *query.Fragment) []Sel return []Selection{&TypeAssertion{ TypeAssertion: *a, - Sels: applySelectionSet(r, a.TypeExec.(*resolvable.Object), frag.Selections), + Sels: applySelectionSet(r, s, a.TypeExec.(*resolvable.Object), frag.Selections), }} } - return applySelectionSet(r, e, frag.Selections) + return applySelectionSet(r, s, e, frag.Selections) } -func applyField(r *Request, e resolvable.Resolvable, sels []query.Selection) []Selection { +func applyField(r *Request, s *resolvable.Schema, e resolvable.Resolvable, sels []query.Selection) []Selection { switch e := e.(type) { case *resolvable.Object: - return applySelectionSet(r, e, sels) + return applySelectionSet(r, s, e, sels) case *resolvable.List: - return applyField(r, e.Elem, sels) + return applyField(r, s, e.Elem, sels) case *resolvable.Scalar: return nil default: diff --git a/internal/exec/subscribe.go b/internal/exec/subscribe.go index b9ac6327..6c7ea1a0 100644 --- a/internal/exec/subscribe.go +++ b/internal/exec/subscribe.go @@ -29,7 +29,7 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *query sels := selected.ApplyOperation(&r.Request, s, op) var fields []*fieldToExec - collectFieldsToResolve(sels, s.Resolver, &fields, make(map[string]*fieldToExec)) + collectFieldsToResolve(sels, s, s.Resolver, &fields, make(map[string]*fieldToExec)) // TODO: move this check into validation.Validate if len(fields) != 1 { @@ -120,7 +120,7 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *query defer subR.handlePanic(subCtx) var buf bytes.Buffer - subR.execSelectionSet(subCtx, f.sels, f.field.Type, &pathSegment{nil, f.field.Alias}, resp, &buf) + subR.execSelectionSet(subCtx, f.sels, f.field.Type, &pathSegment{nil, f.field.Alias}, s, resp, &buf) propagateChildError := false if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild && resolvedToNull(&buf) { diff --git a/internal/schema/meta.go b/internal/schema/meta.go index 365e740a..2e311830 100644 --- a/internal/schema/meta.go +++ b/internal/schema/meta.go @@ -1,13 +1,20 @@ package schema -var Meta *Schema - func init() { - Meta = &Schema{} // bootstrap - Meta = New() - if err := Meta.Parse(metaSrc, false); err != nil { + _ = newMeta() +} + +// newMeta initializes an instance of the meta Schema. +func newMeta() *Schema { + s := &Schema{ + entryPointNames: make(map[string]string), + Types: make(map[string]NamedType), + Directives: make(map[string]*DirectiveDecl), + } + if err := s.Parse(metaSrc, false); err != nil { panic(err) } + return s } var metaSrc = ` diff --git a/internal/schema/schema.go b/internal/schema/schema.go index dafa65b1..982e225b 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -238,10 +238,11 @@ func New() *Schema { Types: make(map[string]NamedType), Directives: make(map[string]*DirectiveDecl), } - for n, t := range Meta.Types { + m := newMeta() + for n, t := range m.Types { s.Types[n] = t } - for n, d := range Meta.Directives { + for n, d := range m.Directives { s.Directives[n] = d } return s From f4657ce89a5e29059546e9f582b0e85c2a2ce761 Mon Sep 17 00:00:00 2001 From: David Ackroyd Date: Sat, 4 May 2019 00:09:30 +1000 Subject: [PATCH 61/69] Fix Enum validation Validating enum values supplied via variable input, and those returned from resolvers, ensuring that only valid enum values defined by the GraphQL schema are accepted --- gqltesting/testing.go | 8 + graphql.go | 4 +- graphql_test.go | 155 ++++++++++++++++++ internal/exec/exec.go | 17 +- internal/validation/testdata/tests.json | 152 ++++++++++++++++- .../validation/validate_max_depth_test.go | 2 +- internal/validation/validation.go | 42 ++++- internal/validation/validation_test.go | 3 +- subscriptions.go | 2 +- 9 files changed, 377 insertions(+), 8 deletions(-) diff --git a/gqltesting/testing.go b/gqltesting/testing.go index dc89fdc4..b1544fd9 100644 --- a/gqltesting/testing.go +++ b/gqltesting/testing.go @@ -48,6 +48,14 @@ func RunTest(t *testing.T, test *Test) { checkErrors(t, test.ExpectedErrors, result.Errors) + if test.ExpectedResult == "" { + if result.Data != nil { + t.Fatalf("got: %s", result.Data) + t.Fatalf("want: null") + } + return + } + // Verify JSON to avoid red herring errors. got, err := formatJSON(result.Data) if err != nil { diff --git a/graphql.go b/graphql.go index be0e1c41..b5674bbc 100644 --- a/graphql.go +++ b/graphql.go @@ -149,7 +149,7 @@ func (s *Schema) Validate(queryString string) []*errors.QueryError { return []*errors.QueryError{qErr} } - return validation.Validate(s.schema, doc, s.maxDepth) + return validation.Validate(s.schema, doc, nil, s.maxDepth) } // Exec executes the given query with the schema's resolver. It panics if the schema was created @@ -169,7 +169,7 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str } validationFinish := s.validationTracer.TraceValidation() - errs := validation.Validate(s.schema, doc, s.maxDepth) + errs := validation.Validate(s.schema, doc, variables, s.maxDepth) validationFinish(errs) if len(errs) != 0 { return &Response{Errors: errs} diff --git a/graphql_test.go b/graphql_test.go index eb4bb974..528599ac 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -1163,6 +1163,161 @@ func TestDeprecatedDirective(t *testing.T) { }) } +type testBadEnumResolver struct {} + +func (r *testBadEnumResolver) Hero() *testBadEnumCharacterResolver { + return &testBadEnumCharacterResolver{} +} + +type testBadEnumCharacterResolver struct {} + +func (r *testBadEnumCharacterResolver) Name() string { + return "Spock" +} + +func (r *testBadEnumCharacterResolver) AppearsIn() []string { + return []string{"STAR_TREK"} +} + +func TestEnums(t *testing.T) { + gqltesting.RunTests(t, []*gqltesting.Test{ + // Valid input enum supplied in query text + { + Schema: starwarsSchema, + Query: ` + query HeroForEpisode { + hero(episode: EMPIRE) { + name + } + } + `, + ExpectedResult: ` + { + "hero": { + "name": "Luke Skywalker" + } + } + `, + }, + // Invalid input enum supplied in query text + { + Schema: starwarsSchema, + Query: ` + query HeroForEpisode { + hero(episode: WRATH_OF_KHAN) { + name + } + } + `, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: "Argument \"episode\" has invalid value WRATH_OF_KHAN.\nExpected type \"Episode\", found WRATH_OF_KHAN.", + Locations: []gqlerrors.Location{{Column: 20, Line: 3}}, + Rule: "ArgumentsOfCorrectType", + }, + }, + }, + // Valid input enum supplied in variables + { + Schema: starwarsSchema, + Query: ` + query HeroForEpisode($episode: Episode!) { + hero(episode: $episode) { + name + } + } + `, + Variables: map[string]interface{}{"episode": "JEDI"}, + ExpectedResult: ` + { + "hero": { + "name": "R2-D2" + } + } + `, + }, + // Invalid input enum supplied in variables + { + Schema: starwarsSchema, + Query: ` + query HeroForEpisode($episode: Episode!) { + hero(episode: $episode) { + name + } + } + `, + Variables: map[string]interface{}{"episode": "FINAL_FRONTIER"}, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: "Variable \"episode\" has invalid value FINAL_FRONTIER.\nExpected type \"Episode\", found FINAL_FRONTIER.", + Locations: []gqlerrors.Location{{Column: 26, Line: 2}}, + Rule: "VariablesOfCorrectType", + }, + }, + }, + // Valid enum value in response + { + Schema: starwarsSchema, + Query: ` + query Hero { + hero { + name + appearsIn + } + } + `, + ExpectedResult: ` + { + "hero": { + "name": "R2-D2", + "appearsIn": ["NEWHOPE","EMPIRE","JEDI"] + } + } + `, + }, + // Invalid enum value in response + { + Schema: graphql.MustParseSchema(` + schema { + query: Query + } + + type Query { + hero: Character + } + + enum Episode { + NEWHOPE + EMPIRE + JEDI + } + + type Character { + name: String! + appearsIn: [Episode!]! + } + `, &testBadEnumResolver{}), + Query: ` + query Hero { + hero { + name + appearsIn + } + } + `, + ExpectedResult: `{ + "hero": null + }`, + ExpectedErrors: []*gqlerrors.QueryError{ + { + Message: "Invalid value STAR_TREK.\nExpected type Episode, found STAR_TREK.", + Path: []interface{}{"hero", "appearsIn", 0}, + }, + }, + }, + }) +} + func TestInlineFragments(t *testing.T) { gqltesting.RunTests(t, []*gqltesting.Test{ { diff --git a/internal/exec/exec.go b/internal/exec/exec.go index f6ec84fb..cabf0966 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -284,8 +284,23 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio if s, ok := resolver.Interface().(fmt.Stringer); ok { stringer = s } + name := stringer.String() + var valid bool + for _, v := range t.Values { + if v.Name == name { + valid = true + break + } + } + if !valid { + err := errors.Errorf("Invalid value %s.\nExpected type %s, found %s.", name, t.Name, name) + err.Path = path.toSlice() + r.AddError(err) + out.WriteString("null") + return + } out.WriteByte('"') - out.WriteString(stringer.String()) + out.WriteString(name) out.WriteByte('"') default: diff --git a/internal/validation/testdata/tests.json b/internal/validation/testdata/tests.json index e6d2bc0f..69a2f146 100644 --- a/internal/validation/testdata/tests.json +++ b/internal/validation/testdata/tests.json @@ -1,6 +1,6 @@ { "schemas": [ - "schema {\n query: QueryRoot\n}\n\ndirective @onQuery on QUERY\n\ndirective @onMutation on MUTATION\n\ndirective @onSubscription on SUBSCRIPTION\n\ndirective @onField on FIELD\n\ndirective @onFragmentDefinition on FRAGMENT_DEFINITION\n\ndirective @onFragmentSpread on FRAGMENT_SPREAD\n\ndirective @onInlineFragment on INLINE_FRAGMENT\n\ndirective @onSchema on SCHEMA\n\ndirective @onScalar on SCALAR\n\ndirective @onObject on OBJECT\n\ndirective @onFieldDefinition on FIELD_DEFINITION\n\ndirective @onArgumentDefinition on ARGUMENT_DEFINITION\n\ndirective @onInterface on INTERFACE\n\ndirective @onUnion on UNION\n\ndirective @onEnum on ENUM\n\ndirective @onEnumValue on ENUM_VALUE\n\ndirective @onInputObject on INPUT_OBJECT\n\ndirective @onInputFieldDefinition on INPUT_FIELD_DEFINITION\n\ntype Alien implements Being & Intelligent {\n iq: Int\n name(surname: Boolean): String\n numEyes: Int\n}\n\nscalar Any\n\ninterface Being {\n name(surname: Boolean): String\n}\n\ninterface Canine {\n name(surname: Boolean): String\n}\n\ntype Cat implements Being & Pet {\n name(surname: Boolean): String\n nickname: String\n meows: Boolean\n meowVolume: Int\n furColor: FurColor\n}\n\nunion CatOrDog = Dog | Cat\n\ninput ComplexInput {\n requiredField: Boolean!\n intField: Int\n stringField: String\n booleanField: Boolean\n stringListField: [String]\n}\n\ntype ComplicatedArgs {\n intArgField(intArg: Int): String\n nonNullIntArgField(nonNullIntArg: Int!): String\n stringArgField(stringArg: String): String\n booleanArgField(booleanArg: Boolean): String\n enumArgField(enumArg: FurColor): String\n floatArgField(floatArg: Float): String\n idArgField(idArg: ID): String\n stringListArgField(stringListArg: [String]): String\n stringListNonNullArgField(stringListNonNullArg: [String!]): String\n complexArgField(complexArg: ComplexInput): String\n multipleReqs(req1: Int!, req2: Int!): String\n multipleOpts(opt1: Int = 0, opt2: Int = 0): String\n multipleOptAndReq(req1: Int!, req2: Int!, opt1: Int = 0, opt2: Int = 0): String\n}\n\ntype Dog implements Being & Pet & Canine {\n name(surname: Boolean): String\n nickname: String\n barkVolume: Int\n barks: Boolean\n doesKnowCommand(dogCommand: DogCommand): Boolean\n isHousetrained(atOtherHomes: Boolean = true): Boolean\n isAtLocation(x: Int, y: Int): Boolean\n}\n\nenum DogCommand {\n SIT\n HEEL\n DOWN\n}\n\nunion DogOrHuman = Dog | Human\n\nenum FurColor {\n BROWN\n BLACK\n TAN\n SPOTTED\n NO_FUR\n UNKNOWN\n}\n\ntype Human implements Being & Intelligent {\n name(surname: Boolean): String\n pets: [Pet]\n relatives: [Human]\n iq: Int\n}\n\nunion HumanOrAlien = Human | Alien\n\ninterface Intelligent {\n iq: Int\n}\n\nscalar Invalid\n\ninterface Pet {\n name(surname: Boolean): String\n}\n\ntype QueryRoot {\n human(id: ID): Human\n alien: Alien\n dog: Dog\n cat: Cat\n pet: Pet\n catOrDog: CatOrDog\n dogOrHuman: DogOrHuman\n humanOrAlien: HumanOrAlien\n complicatedArgs: ComplicatedArgs\n invalidArg(arg: Invalid): String\n anyArg(arg: Any): String\n}\n", + "schema {\n query: QueryRoot\n}\n\ndirective @onQuery on QUERY\n\ndirective @onMutation on MUTATION\n\ndirective @onSubscription on SUBSCRIPTION\n\ndirective @onField on FIELD\n\ndirective @onFragmentDefinition on FRAGMENT_DEFINITION\n\ndirective @onFragmentSpread on FRAGMENT_SPREAD\n\ndirective @onInlineFragment on INLINE_FRAGMENT\n\ndirective @onSchema on SCHEMA\n\ndirective @onScalar on SCALAR\n\ndirective @onObject on OBJECT\n\ndirective @onFieldDefinition on FIELD_DEFINITION\n\ndirective @onArgumentDefinition on ARGUMENT_DEFINITION\n\ndirective @onInterface on INTERFACE\n\ndirective @onUnion on UNION\n\ndirective @onEnum on ENUM\n\ndirective @onEnumValue on ENUM_VALUE\n\ndirective @onInputObject on INPUT_OBJECT\n\ndirective @onInputFieldDefinition on INPUT_FIELD_DEFINITION\n\ntype Alien implements Being & Intelligent {\n iq: Int\n name(surname: Boolean): String\n numEyes: Int\n}\n\nscalar Any\n\ninterface Being {\n name(surname: Boolean): String\n}\n\ninterface Canine {\n name(surname: Boolean): String\n}\n\ntype Cat implements Being & Pet {\n name(surname: Boolean): String\n nickname: String\n meows: Boolean\n meowVolume: Int\n furColor: FurColor\n}\n\nunion CatOrDog = Dog | Cat\n\ninput ComplexInput {\n requiredField: Boolean!\n intField: Int\n stringField: String\n booleanField: Boolean\n stringListField: [String]\n}\n\ntype ComplicatedArgs {\n intArgField(intArg: Int): String\n nonNullIntArgField(nonNullIntArg: Int!): String\n stringArgField(stringArg: String): String\n booleanArgField(booleanArg: Boolean): String\n enumArgField(enumArg: FurColor): String\n enumArrayArgField(enumArrayArg: [FurColor!]!): String\n floatArgField(floatArg: Float): String\n idArgField(idArg: ID): String\n stringListArgField(stringListArg: [String]): String\n stringListNonNullArgField(stringListNonNullArg: [String!]): String\n complexArgField(complexArg: ComplexInput): String\n multipleReqs(req1: Int!, req2: Int!): String\n multipleOpts(opt1: Int = 0, opt2: Int = 0): String\n multipleOptAndReq(req1: Int!, req2: Int!, opt1: Int = 0, opt2: Int = 0): String\n}\n\ntype Dog implements Being & Pet & Canine {\n name(surname: Boolean): String\n nickname: String\n barkVolume: Int\n barks: Boolean\n doesKnowCommand(dogCommand: DogCommand): Boolean\n isHousetrained(atOtherHomes: Boolean = true): Boolean\n isAtLocation(x: Int, y: Int): Boolean\n}\n\nenum DogCommand {\n SIT\n HEEL\n DOWN\n}\n\nunion DogOrHuman = Dog | Human\n\nenum FurColor {\n BROWN\n BLACK\n TAN\n SPOTTED\n NO_FUR\n UNKNOWN\n}\n\ntype Human implements Being & Intelligent {\n name(surname: Boolean): String\n pets: [Pet]\n relatives: [Human]\n iq: Int\n}\n\nunion HumanOrAlien = Human | Alien\n\ninterface Intelligent {\n iq: Int\n}\n\nscalar Invalid\n\ninterface Pet {\n name(surname: Boolean): String\n}\n\ntype QueryRoot {\n human(id: ID): Human\n alien: Alien\n dog: Dog\n cat: Cat\n pet: Pet\n catOrDog: CatOrDog\n dogOrHuman: DogOrHuman\n humanOrAlien: HumanOrAlien\n complicatedArgs: ComplicatedArgs\n invalidArg(arg: Invalid): String\n anyArg(arg: Any): String\n}\n", "schema {\n query: QueryRoot\n}\n\ntype Connection {\n edges: [Edge]\n}\n\ntype Edge {\n node: Node\n}\n\ntype IntBox implements SomeBox {\n scalar: Int\n deepBox: IntBox\n unrelatedField: String\n listStringBox: [StringBox]\n stringBox: StringBox\n intBox: IntBox\n}\n\ntype Node {\n id: ID\n name: String\n}\n\ninterface NonNullStringBox1 {\n scalar: String!\n}\n\ntype NonNullStringBox1Impl implements SomeBox & NonNullStringBox1 {\n scalar: String!\n unrelatedField: String\n deepBox: SomeBox\n}\n\ninterface NonNullStringBox2 {\n scalar: String!\n}\n\ntype NonNullStringBox2Impl implements SomeBox & NonNullStringBox2 {\n scalar: String!\n unrelatedField: String\n deepBox: SomeBox\n}\n\ntype QueryRoot {\n someBox: SomeBox\n connection: Connection\n}\n\ninterface SomeBox {\n deepBox: SomeBox\n unrelatedField: String\n}\n\ntype StringBox implements SomeBox {\n scalar: String\n deepBox: StringBox\n unrelatedField: String\n listStringBox: [StringBox]\n stringBox: StringBox\n intBox: IntBox\n}\n", "type Foo {\n constructor: String\n}\n\ntype Query {\n foo: Foo\n}\n" ], @@ -1614,6 +1614,156 @@ } ] }, + { + "name": "Validate: Arguments have valid type/valid enum constant in query", + "rule": "ArgumentsOfCorrectType", + "schema": 0, + "query": "\n query Query {\n complicatedArgs {\n enumArgField(enumArg: BROWN)\n }\n }\n ", + "errors": [] + }, + { + "name": "Validate: Arguments have valid type/invalid enum constant in query text", + "rule": "ArgumentsOfCorrectType", + "schema": 0, + "query": "\n query Query {\n complicatedArgs {\n enumArgField(enumArg: RAINBOW)\n }\n }\n ", + "errors": [ + { + "message": "Argument \"enumArg\" has invalid value RAINBOW.\nExpected type \"FurColor\", found RAINBOW.", + "locations": [ + { + "line": 4, + "column": 33 + } + ] + } + ] + }, + { + "name": "Validate: Arguments have valid type/optional enum constant in query", + "rule": "ArgumentsOfCorrectType", + "schema": 0, + "query": "\n query Query {\n complicatedArgs {\n enumArgField()\n }\n }\n ", + "errors": [] + }, + { + "name": "Validate: Variables have valid type/valid enum constant in variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($color: FurColor) {\n complicatedArgs {\n enumArgField(enumArg: $color)\n }\n }\n ", + "vars": { + "color": "BROWN" + }, + "errors": [] + }, + { + "name": "Validate: Variables have valid type/invalid enum constant in variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($color: FurColor) {\n complicatedArgs {\n enumArgField(enumArg: $color)\n }\n }\n ", + "vars": { + "color": "RAINBOW" + }, + "errors": [ + { + "message": "Variable \"color\" has invalid value RAINBOW.\nExpected type \"FurColor\", found RAINBOW.", + "locations": [ + { + "line": 2, + "column": 19 + } + ] + } + ] + }, + { + "name": "Validate: Variables have valid type/optional enum constant variable null", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($color: FurColor) {\n complicatedArgs {\n enumArgField(enumArg: $color)\n }\n }\n ", + "vars": { + "color": null + }, + "errors": [] + }, + { + "name": "Validate: Variables have valid type/number as enum", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($color: FurColor) {\n complicatedArgs {\n enumArgField(enumArg: $color)\n }\n }\n ", + "vars": { + "color": 42 + }, + "errors": [ + { + "message": "Variable \"color\" has invalid type float64.\nExpected type \"FurColor\", found 42.", + "locations": [ + { + "line": 2, + "column": 19 + } + ] + } + ] + }, + { + "name": "Validate: Variables have valid type/valid enum array constant in variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($colors: [FurColor!]!) {\n complicatedArgs {\n enumArrayArgField(enumArrayArg: $colors)\n }\n }\n ", + "vars": { + "colors": ["BROWN", "BLACK", "SPOTTED"] + }, + "errors": [] + }, + { + "name": "Validate: Variables have valid type/invalid enum array constant in variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($colors: [FurColor!]!) {\n complicatedArgs {\n enumArrayArgField(enumArrayArg: $colors)\n }\n }\n ", + "vars": { + "colors": ["TEAL", "AUBERGINE"] + }, + "errors": [ + { + "message": "Variable \"colors\" has invalid value TEAL.\nExpected type \"FurColor\", found TEAL.", + "locations": [ + { + "line": 2, + "column": 19 + } + ] + }, + { + "message": "Variable \"colors\" has invalid value AUBERGINE.\nExpected type \"FurColor\", found AUBERGINE.", + "locations": [ + { + "line": 2, + "column": 19 + } + ] + } + ] + }, + { + "name": "Validate: Variables have valid type/string as enum array variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($colors: [FurColor!]!) {\n complicatedArgs {\n enumArrayArgField(enumArrayArg: $colors)\n }\n }\n ", + "vars": { + "colors": "BROWN" + }, + "errors": [ + { + "message": "Variable \"colors\" has invalid type string.\nExpected type \"[FurColor!]\", found BROWN.", + "locations": [ + { + "line": 2, + "column": 19 + } + ] + } + ] + }, { "name": "Validate: Overlapping fields can be merged/unique fields", "rule": "OverlappingFieldsCanBeMerged", diff --git a/internal/validation/validate_max_depth_test.go b/internal/validation/validate_max_depth_test.go index 4dc13e66..abc337cb 100644 --- a/internal/validation/validate_max_depth_test.go +++ b/internal/validation/validate_max_depth_test.go @@ -77,7 +77,7 @@ func (tc maxDepthTestCase) Run(t *testing.T, s *schema.Schema) { t.Fatal(qErr) } - errs := Validate(s, doc, tc.depth) + errs := Validate(s, doc, nil, tc.depth) if len(tc.expectedErrors) > 0 { if len(errs) > 0 { for _, expected := range tc.expectedErrors { diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 94ad5ca7..dc95c0c9 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -63,7 +63,7 @@ func newContext(s *schema.Schema, doc *query.Document, maxDepth int) *context { } } -func Validate(s *schema.Schema, doc *query.Document, maxDepth int) []*errors.QueryError { +func Validate(s *schema.Schema, doc *query.Document, variables map[string]interface{}, maxDepth int) []*errors.QueryError { c := newContext(s, doc, maxDepth) opNames := make(nameSet) @@ -95,6 +95,7 @@ func Validate(s *schema.Schema, doc *query.Document, maxDepth int) []*errors.Que if !canBeInput(t) { c.addErr(v.TypeLoc, "VariablesAreInputTypes", "Variable %q cannot be non-input type %q.", "$"+v.Name.Name, t) } + validateValue(opc, v, variables[v.Name.Name], t) if v.Default != nil { validateLiteral(opc, v.Default) @@ -178,6 +179,44 @@ func Validate(s *schema.Schema, doc *query.Document, maxDepth int) []*errors.Que return c.errs } +func validateValue(c *opContext, v *common.InputValue, val interface{}, t common.Type) { + switch t := t.(type) { + case *common.NonNull: + if val == nil { + c.addErr(v.Loc, "VariablesOfCorrectType", "Variable \"%s\" has invalid value null.\nExpected type \"%s\", found null.", v.Name.Name, t) + return + } + validateValue(c, v, val, t.OfType) + case *common.List: + if val == nil { + return + } + vv, ok := val.([]interface{}) + if !ok { + c.addErr(v.Loc, "VariablesOfCorrectType", "Variable \"%s\" has invalid type %T.\nExpected type \"%s\", found %v.", v.Name.Name, val, t, val) + return + } + for _, elem := range vv { + validateValue(c, v, elem, t.OfType) + } + case *schema.Enum: + if val == nil { + return + } + e, ok := val.(string) + if !ok { + c.addErr(v.Loc, "VariablesOfCorrectType", "Variable \"%s\" has invalid type %T.\nExpected type \"%s\", found %v.", v.Name.Name, val, t, val) + return + } + for _, option := range t.Values { + if option.Name == e { + return + } + } + c.addErr(v.Loc, "VariablesOfCorrectType", "Variable \"%s\" has invalid value %s.\nExpected type \"%s\", found %s.", v.Name.Name, e, t, e) + } +} + // validates the query doesn't go deeper than maxDepth (if set). Returns whether // or not query validated max depth to avoid excessive recursion. func validateMaxDepth(c *opContext, sels []query.Selection, depth int) bool { @@ -686,6 +725,7 @@ func validateLiteral(c *opContext, l common.Literal) { }) continue } + validateValueType(c, l, resolveType(c.context, v.Type)) c.usedVars[op][v] = struct{}{} } } diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go index 52b6f2c6..e287a526 100644 --- a/internal/validation/validation_test.go +++ b/internal/validation/validation_test.go @@ -19,6 +19,7 @@ type Test struct { Rule string Schema int Query string + Vars map[string]interface{} Errors []*errors.QueryError } @@ -50,7 +51,7 @@ func TestValidate(t *testing.T) { if err != nil { t.Fatal(err) } - errs := validation.Validate(schemas[test.Schema], d, 0) + errs := validation.Validate(schemas[test.Schema], d, test.Vars, 0) got := []*errors.QueryError{} for _, err := range errs { if err.Rule == test.Rule { diff --git a/subscriptions.go b/subscriptions.go index fbec253a..ccb73ef3 100644 --- a/subscriptions.go +++ b/subscriptions.go @@ -33,7 +33,7 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam } validationFinish := s.validationTracer.TraceValidation() - errs := validation.Validate(s.schema, doc, s.maxDepth) + errs := validation.Validate(s.schema, doc, variables, s.maxDepth) validationFinish(errs) if len(errs) != 0 { return sendAndReturnClosed(&Response{Errors: errs}) From c126f0ee085204e819941a9c615af1abcef09fd2 Mon Sep 17 00:00:00 2001 From: David Ackroyd Date: Wed, 8 May 2019 12:39:47 +1000 Subject: [PATCH 62/69] Fix Panic on Introspection of Schema using ToJSON Introspection via ToJSON works differently when used via the `Schema.ToJSON` function than when making an introspection query from a client such as GraphiQL. In the later case, introspection uses the Schema's registered resolver, while in the former case, a `resolvable.Schema` is faked Without the `Meta` being set on this `resolvable.Schema` (following the recent changes to address race conditions around the Meta schema), this resulted in a panic when using this form of schema introspection. --- example/social/introspect.json | 1346 ++++++++++++++++ example/starwars/introspect.json | 2026 ++++++++++++++++++++++++ graphql.go | 13 +- graphql_test.go | 55 + internal/exec/resolvable/resolvable.go | 4 + introspection.go | 1 + introspection_test.go | 89 ++ subscription_test.go | 2 +- subscriptions.go | 3 +- 9 files changed, 3530 insertions(+), 9 deletions(-) create mode 100644 example/social/introspect.json create mode 100644 example/starwars/introspect.json create mode 100644 introspection_test.go diff --git a/example/social/introspect.json b/example/social/introspect.json new file mode 100644 index 00000000..88c4c00b --- /dev/null +++ b/example/social/introspect.json @@ -0,0 +1,1346 @@ +{ + "__schema": { + "directives": [ + { + "args": [ + { + "defaultValue": "\"No longer supported\"", + "description": "Explains why this element was deprecated, usually also including a suggestion\nfor how to access supported similar data. Formatted in\n[Markdown](https://daringfireball.net/projects/markdown/).", + "name": "reason", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "description": "Marks an element of a GraphQL schema as no longer supported.", + "locations": [ + "FIELD_DEFINITION", + "ENUM_VALUE" + ], + "name": "deprecated" + }, + { + "args": [ + { + "defaultValue": null, + "description": "Included when true.", + "name": "if", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], + "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "name": "include" + }, + { + "args": [ + { + "defaultValue": null, + "description": "Skipped when true.", + "name": "if", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], + "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "name": "skip" + } + ], + "mutationType": null, + "queryType": { + "name": "Query" + }, + "subscriptionType": null, + "types": [ + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "role", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Role", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": null, + "kind": "INTERFACE", + "name": "Admin", + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + ] + }, + { + "description": "The `Boolean` scalar type represents `true` or `false`.", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "Boolean", + "possibleTypes": null + }, + { + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "Float", + "possibleTypes": null + }, + { + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "ID", + "possibleTypes": null + }, + { + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "Int", + "possibleTypes": null + }, + { + "description": null, + "enumValues": null, + "fields": null, + "inputFields": [ + { + "defaultValue": null, + "description": null, + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + { + "defaultValue": null, + "description": null, + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + ], + "interfaces": null, + "kind": "INPUT_OBJECT", + "name": "Pagination", + "possibleTypes": null + }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + { + "defaultValue": "ADMIN", + "description": null, + "name": "role", + "type": { + "kind": "ENUM", + "name": "Role", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "admin", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Admin", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "user", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "text", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "search", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "UNION", + "name": "SearchResult", + "ofType": null + } + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Query", + "possibleTypes": null + }, + { + "description": null, + "enumValues": [ + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "ADMIN" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "USER" + } + ], + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "ENUM", + "name": "Role", + "possibleTypes": null + }, + { + "description": null, + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "UNION", + "name": "SearchResult", + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + ] + }, + { + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "String", + "possibleTypes": null + }, + { + "description": null, + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "Time", + "possibleTypes": null + }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "email", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "role", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Role", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "phone", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "address", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "page", + "type": { + "kind": "INPUT_OBJECT", + "name": "Pagination", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "friends", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "createdAt", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Admin", + "ofType": null + } + ], + "kind": "OBJECT", + "name": "User", + "possibleTypes": null + }, + { + "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL's execution behavior\nin ways field arguments will not suffice, such as conditionally including or\nskipping a field. Directives provide this by describing additional information\nto the executor.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "locations", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__DirectiveLocation", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "args", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__Directive", + "possibleTypes": null + }, + { + "description": "A Directive can be adjacent to many parts of the GraphQL language, a\n__DirectiveLocation describes one such possible adjacencies.", + "enumValues": [ + { + "deprecationReason": null, + "description": "Location adjacent to a query operation.", + "isDeprecated": false, + "name": "QUERY" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a mutation operation.", + "isDeprecated": false, + "name": "MUTATION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a subscription operation.", + "isDeprecated": false, + "name": "SUBSCRIPTION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a field.", + "isDeprecated": false, + "name": "FIELD" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a fragment definition.", + "isDeprecated": false, + "name": "FRAGMENT_DEFINITION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a fragment spread.", + "isDeprecated": false, + "name": "FRAGMENT_SPREAD" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an inline fragment.", + "isDeprecated": false, + "name": "INLINE_FRAGMENT" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a schema definition.", + "isDeprecated": false, + "name": "SCHEMA" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a scalar definition.", + "isDeprecated": false, + "name": "SCALAR" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an object type definition.", + "isDeprecated": false, + "name": "OBJECT" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a field definition.", + "isDeprecated": false, + "name": "FIELD_DEFINITION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an argument definition.", + "isDeprecated": false, + "name": "ARGUMENT_DEFINITION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an interface definition.", + "isDeprecated": false, + "name": "INTERFACE" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a union definition.", + "isDeprecated": false, + "name": "UNION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an enum definition.", + "isDeprecated": false, + "name": "ENUM" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an enum value definition.", + "isDeprecated": false, + "name": "ENUM_VALUE" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an input object type definition.", + "isDeprecated": false, + "name": "INPUT_OBJECT" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an input object field definition.", + "isDeprecated": false, + "name": "INPUT_FIELD_DEFINITION" + } + ], + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "ENUM", + "name": "__DirectiveLocation", + "possibleTypes": null + }, + { + "description": "One possible value for a given Enum. Enum values are unique values, not a\nplaceholder for a string or numeric value. However an Enum value is returned in\na JSON response as a string.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "isDeprecated", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "deprecationReason", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__EnumValue", + "possibleTypes": null + }, + { + "description": "Object and Interface types are described by a list of Fields, each of which has\na name, potentially a list of arguments, and a return type.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "args", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "type", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "isDeprecated", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "deprecationReason", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__Field", + "possibleTypes": null + }, + { + "description": "Arguments provided to Fields or Directives and the input fields of an\nInputObject are represented as Input Values which describe their type and\noptionally a default value.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "type", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "A GraphQL-formatted string representing the default value for this input value.", + "isDeprecated": false, + "name": "defaultValue", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__InputValue", + "possibleTypes": null + }, + { + "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all\navailable types and directives on the server, as well as the entry points for\nquery, mutation, and subscription operations.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "A list of all types supported by this server.", + "isDeprecated": false, + "name": "types", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The type that query operations will be rooted at.", + "isDeprecated": false, + "name": "queryType", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "If this server supports mutation, the type that mutation operations will be rooted at.", + "isDeprecated": false, + "name": "mutationType", + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": "If this server support subscription, the type that subscription operations will be rooted at.", + "isDeprecated": false, + "name": "subscriptionType", + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": "A list of all directives supported by this server.", + "isDeprecated": false, + "name": "directives", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + } + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__Schema", + "possibleTypes": null + }, + { + "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of\ntypes in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that\ntype. Scalar types provide no information beyond a name and description, while\nEnum types provide their values. Object and Interface types provide the fields\nthey describe. Abstract types, Union and Interface, provide the Object types\npossible at runtime. List and NonNull types compose other types.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "kind", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__TypeKind", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [ + { + "defaultValue": "false", + "description": null, + "name": "includeDeprecated", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "fields", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "interfaces", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "possibleTypes", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + { + "args": [ + { + "defaultValue": "false", + "description": null, + "name": "includeDeprecated", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "enumValues", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "inputFields", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "ofType", + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__Type", + "possibleTypes": null + }, + { + "description": "An enum describing what kind of type a given `__Type` is.", + "enumValues": [ + { + "deprecationReason": null, + "description": "Indicates this type is a scalar.", + "isDeprecated": false, + "name": "SCALAR" + }, + { + "deprecationReason": null, + "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", + "isDeprecated": false, + "name": "OBJECT" + }, + { + "deprecationReason": null, + "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", + "isDeprecated": false, + "name": "INTERFACE" + }, + { + "deprecationReason": null, + "description": "Indicates this type is a union. `possibleTypes` is a valid field.", + "isDeprecated": false, + "name": "UNION" + }, + { + "deprecationReason": null, + "description": "Indicates this type is an enum. `enumValues` is a valid field.", + "isDeprecated": false, + "name": "ENUM" + }, + { + "deprecationReason": null, + "description": "Indicates this type is an input object. `inputFields` is a valid field.", + "isDeprecated": false, + "name": "INPUT_OBJECT" + }, + { + "deprecationReason": null, + "description": "Indicates this type is a list. `ofType` is a valid field.", + "isDeprecated": false, + "name": "LIST" + }, + { + "deprecationReason": null, + "description": "Indicates this type is a non-null. `ofType` is a valid field.", + "isDeprecated": false, + "name": "NON_NULL" + } + ], + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "ENUM", + "name": "__TypeKind", + "possibleTypes": null + } + ] + } +} \ No newline at end of file diff --git a/example/starwars/introspect.json b/example/starwars/introspect.json new file mode 100644 index 00000000..2b955ee9 --- /dev/null +++ b/example/starwars/introspect.json @@ -0,0 +1,2026 @@ +{ + "__schema": { + "directives": [ + { + "args": [ + { + "defaultValue": "\"No longer supported\"", + "description": "Explains why this element was deprecated, usually also including a suggestion\nfor how to access supported similar data. Formatted in\n[Markdown](https://daringfireball.net/projects/markdown/).", + "name": "reason", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "description": "Marks an element of a GraphQL schema as no longer supported.", + "locations": [ + "FIELD_DEFINITION", + "ENUM_VALUE" + ], + "name": "deprecated" + }, + { + "args": [ + { + "defaultValue": null, + "description": "Included when true.", + "name": "if", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], + "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "name": "include" + }, + { + "args": [ + { + "defaultValue": null, + "description": "Skipped when true.", + "name": "if", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], + "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "name": "skip" + } + ], + "mutationType": { + "name": "Mutation" + }, + "queryType": { + "name": "Query" + }, + "subscriptionType": null, + "types": [ + { + "description": "The `Boolean` scalar type represents `true` or `false`.", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "Boolean", + "possibleTypes": null + }, + { + "description": "A character from the Star Wars universe", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "The ID of the character", + "isDeprecated": false, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The name of the character", + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The friends of the character, or an empty list if they have none", + "isDeprecated": false, + "name": "friends", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + { + "defaultValue": null, + "description": null, + "name": "after", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": "The friends of the character exposed as a connection with edges", + "isDeprecated": false, + "name": "friendsConnection", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FriendsConnection", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The movies this character appears in", + "isDeprecated": false, + "name": "appearsIn", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + } + } + } + } + } + ], + "inputFields": null, + "interfaces": null, + "kind": "INTERFACE", + "name": "Character", + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Human", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Droid", + "ofType": null + } + ] + }, + { + "description": "An autonomous mechanical character in the Star Wars universe", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "The ID of the droid", + "isDeprecated": false, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "What others call this droid", + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "This droid's friends, or an empty list if they have none", + "isDeprecated": false, + "name": "friends", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + { + "defaultValue": null, + "description": null, + "name": "after", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": "The friends of the droid exposed as a connection with edges", + "isDeprecated": false, + "name": "friendsConnection", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FriendsConnection", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The movies this droid appears in", + "isDeprecated": false, + "name": "appearsIn", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "This droid's primary function", + "isDeprecated": false, + "name": "primaryFunction", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + ], + "kind": "OBJECT", + "name": "Droid", + "possibleTypes": null + }, + { + "description": "The episodes in the Star Wars trilogy", + "enumValues": [ + { + "deprecationReason": null, + "description": "Star Wars Episode IV: A New Hope, released in 1977.", + "isDeprecated": false, + "name": "NEWHOPE" + }, + { + "deprecationReason": null, + "description": "Star Wars Episode V: The Empire Strikes Back, released in 1980.", + "isDeprecated": false, + "name": "EMPIRE" + }, + { + "deprecationReason": null, + "description": "Star Wars Episode VI: Return of the Jedi, released in 1983.", + "isDeprecated": false, + "name": "JEDI" + } + ], + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "ENUM", + "name": "Episode", + "possibleTypes": null + }, + { + "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "Float", + "possibleTypes": null + }, + { + "description": "A connection object for a character's friends", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "The total number of friends", + "isDeprecated": false, + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The edges for each of the character's friends.", + "isDeprecated": false, + "name": "edges", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FriendsEdge", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "A list of the friends, as a convenience when edges are not needed.", + "isDeprecated": false, + "name": "friends", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "Information for paginating this connection", + "isDeprecated": false, + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "FriendsConnection", + "possibleTypes": null + }, + { + "description": "An edge object for a character's friends", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "A cursor used for pagination", + "isDeprecated": false, + "name": "cursor", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The character represented by this friendship edge", + "isDeprecated": false, + "name": "node", + "type": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "FriendsEdge", + "possibleTypes": null + }, + { + "description": "A humanoid creature from the Star Wars universe", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "The ID of the human", + "isDeprecated": false, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "What this human calls themselves", + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": "METER", + "description": null, + "name": "unit", + "type": { + "kind": "ENUM", + "name": "LengthUnit", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": "Height in the preferred unit, default is meters", + "isDeprecated": false, + "name": "height", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "Mass in kilograms, or null if unknown", + "isDeprecated": false, + "name": "mass", + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": "This human's friends, or an empty list if they have none", + "isDeprecated": false, + "name": "friends", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + { + "defaultValue": null, + "description": null, + "name": "after", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": "The friends of the human exposed as a connection with edges", + "isDeprecated": false, + "name": "friendsConnection", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FriendsConnection", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The movies this human appears in", + "isDeprecated": false, + "name": "appearsIn", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "A list of starships this person has piloted, or an empty list if none", + "isDeprecated": false, + "name": "starships", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Starship", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + ], + "kind": "OBJECT", + "name": "Human", + "possibleTypes": null + }, + { + "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "ID", + "possibleTypes": null + }, + { + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "Int", + "possibleTypes": null + }, + { + "description": "Units of height", + "enumValues": [ + { + "deprecationReason": null, + "description": "The standard unit around the world", + "isDeprecated": false, + "name": "METER" + }, + { + "deprecationReason": null, + "description": "Primarily used in the United States", + "isDeprecated": false, + "name": "FOOT" + } + ], + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "ENUM", + "name": "LengthUnit", + "possibleTypes": null + }, + { + "description": "The mutation type, represents all updates we can make to our data", + "enumValues": null, + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "episode", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + } + } + }, + { + "defaultValue": null, + "description": null, + "name": "review", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ReviewInput", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "createReview", + "type": { + "kind": "OBJECT", + "name": "Review", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Mutation", + "possibleTypes": null + }, + { + "description": "Information for paginating this connection", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "startCursor", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "endCursor", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "hasNextPage", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "PageInfo", + "possibleTypes": null + }, + { + "description": "The query type, represents all of the entry points into our object graph", + "enumValues": null, + "fields": [ + { + "args": [ + { + "defaultValue": "NEWHOPE", + "description": null, + "name": "episode", + "type": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "hero", + "type": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "episode", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Episode", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "reviews", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Review", + "ofType": null + } + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "text", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "search", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "UNION", + "name": "SearchResult", + "ofType": null + } + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "character", + "type": { + "kind": "INTERFACE", + "name": "Character", + "ofType": null + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "droid", + "type": { + "kind": "OBJECT", + "name": "Droid", + "ofType": null + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "human", + "type": { + "kind": "OBJECT", + "name": "Human", + "ofType": null + } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "starship", + "type": { + "kind": "OBJECT", + "name": "Starship", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Query", + "possibleTypes": null + }, + { + "description": "Represents a review for a movie", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "The number of stars this review gave, 1-5", + "isDeprecated": false, + "name": "stars", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "Comment about the movie", + "isDeprecated": false, + "name": "commentary", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Review", + "possibleTypes": null + }, + { + "description": "The input object sent when someone is creating a new review", + "enumValues": null, + "fields": null, + "inputFields": [ + { + "defaultValue": null, + "description": "0-5 stars", + "name": "stars", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + }, + { + "defaultValue": null, + "description": "Comment about the movie, optional", + "name": "commentary", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "interfaces": null, + "kind": "INPUT_OBJECT", + "name": "ReviewInput", + "possibleTypes": null + }, + { + "description": null, + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "UNION", + "name": "SearchResult", + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Human", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Droid", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Starship", + "ofType": null + } + ] + }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "The ID of the starship", + "isDeprecated": false, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The name of the starship", + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": "METER", + "description": null, + "name": "unit", + "type": { + "kind": "ENUM", + "name": "LengthUnit", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": "Length of the starship, along the longest axis", + "isDeprecated": false, + "name": "length", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Starship", + "possibleTypes": null + }, + { + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "String", + "possibleTypes": null + }, + { + "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL's execution behavior\nin ways field arguments will not suffice, such as conditionally including or\nskipping a field. Directives provide this by describing additional information\nto the executor.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "locations", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__DirectiveLocation", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "args", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__Directive", + "possibleTypes": null + }, + { + "description": "A Directive can be adjacent to many parts of the GraphQL language, a\n__DirectiveLocation describes one such possible adjacencies.", + "enumValues": [ + { + "deprecationReason": null, + "description": "Location adjacent to a query operation.", + "isDeprecated": false, + "name": "QUERY" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a mutation operation.", + "isDeprecated": false, + "name": "MUTATION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a subscription operation.", + "isDeprecated": false, + "name": "SUBSCRIPTION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a field.", + "isDeprecated": false, + "name": "FIELD" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a fragment definition.", + "isDeprecated": false, + "name": "FRAGMENT_DEFINITION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a fragment spread.", + "isDeprecated": false, + "name": "FRAGMENT_SPREAD" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an inline fragment.", + "isDeprecated": false, + "name": "INLINE_FRAGMENT" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a schema definition.", + "isDeprecated": false, + "name": "SCHEMA" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a scalar definition.", + "isDeprecated": false, + "name": "SCALAR" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an object type definition.", + "isDeprecated": false, + "name": "OBJECT" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a field definition.", + "isDeprecated": false, + "name": "FIELD_DEFINITION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an argument definition.", + "isDeprecated": false, + "name": "ARGUMENT_DEFINITION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an interface definition.", + "isDeprecated": false, + "name": "INTERFACE" + }, + { + "deprecationReason": null, + "description": "Location adjacent to a union definition.", + "isDeprecated": false, + "name": "UNION" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an enum definition.", + "isDeprecated": false, + "name": "ENUM" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an enum value definition.", + "isDeprecated": false, + "name": "ENUM_VALUE" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an input object type definition.", + "isDeprecated": false, + "name": "INPUT_OBJECT" + }, + { + "deprecationReason": null, + "description": "Location adjacent to an input object field definition.", + "isDeprecated": false, + "name": "INPUT_FIELD_DEFINITION" + } + ], + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "ENUM", + "name": "__DirectiveLocation", + "possibleTypes": null + }, + { + "description": "One possible value for a given Enum. Enum values are unique values, not a\nplaceholder for a string or numeric value. However an Enum value is returned in\na JSON response as a string.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "isDeprecated", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "deprecationReason", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__EnumValue", + "possibleTypes": null + }, + { + "description": "Object and Interface types are described by a list of Fields, each of which has\na name, potentially a list of arguments, and a return type.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "args", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "type", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "isDeprecated", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "deprecationReason", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__Field", + "possibleTypes": null + }, + { + "description": "Arguments provided to Fields or Directives and the input fields of an\nInputObject are represented as Input Values which describe their type and\noptionally a default value.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "type", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "A GraphQL-formatted string representing the default value for this input value.", + "isDeprecated": false, + "name": "defaultValue", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__InputValue", + "possibleTypes": null + }, + { + "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all\navailable types and directives on the server, as well as the entry points for\nquery, mutation, and subscription operations.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "A list of all types supported by this server.", + "isDeprecated": false, + "name": "types", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "The type that query operations will be rooted at.", + "isDeprecated": false, + "name": "queryType", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": "If this server supports mutation, the type that mutation operations will be rooted at.", + "isDeprecated": false, + "name": "mutationType", + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": "If this server support subscription, the type that subscription operations will be rooted at.", + "isDeprecated": false, + "name": "subscriptionType", + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": "A list of all directives supported by this server.", + "isDeprecated": false, + "name": "directives", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + } + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__Schema", + "possibleTypes": null + }, + { + "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of\ntypes in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that\ntype. Scalar types provide no information beyond a name and description, while\nEnum types provide their values. Object and Interface types provide the fields\nthey describe. Abstract types, Union and Interface, provide the Object types\npossible at runtime. List and NonNull types compose other types.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "kind", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__TypeKind", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "description", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [ + { + "defaultValue": "false", + "description": null, + "name": "includeDeprecated", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "fields", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "interfaces", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "possibleTypes", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + { + "args": [ + { + "defaultValue": "false", + "description": null, + "name": "includeDeprecated", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "enumValues", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "inputFields", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "ofType", + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "__Type", + "possibleTypes": null + }, + { + "description": "An enum describing what kind of type a given `__Type` is.", + "enumValues": [ + { + "deprecationReason": null, + "description": "Indicates this type is a scalar.", + "isDeprecated": false, + "name": "SCALAR" + }, + { + "deprecationReason": null, + "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", + "isDeprecated": false, + "name": "OBJECT" + }, + { + "deprecationReason": null, + "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", + "isDeprecated": false, + "name": "INTERFACE" + }, + { + "deprecationReason": null, + "description": "Indicates this type is a union. `possibleTypes` is a valid field.", + "isDeprecated": false, + "name": "UNION" + }, + { + "deprecationReason": null, + "description": "Indicates this type is an enum. `enumValues` is a valid field.", + "isDeprecated": false, + "name": "ENUM" + }, + { + "deprecationReason": null, + "description": "Indicates this type is an input object. `inputFields` is a valid field.", + "isDeprecated": false, + "name": "INPUT_OBJECT" + }, + { + "deprecationReason": null, + "description": "Indicates this type is a list. `ofType` is a valid field.", + "isDeprecated": false, + "name": "LIST" + }, + { + "deprecationReason": null, + "description": "Indicates this type is a non-null. `ofType` is a valid field.", + "isDeprecated": false, + "name": "NON_NULL" + } + ], + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "ENUM", + "name": "__TypeKind", + "possibleTypes": null + } + ] + } +} \ No newline at end of file diff --git a/graphql.go b/graphql.go index b5674bbc..aaa7ebb1 100644 --- a/graphql.go +++ b/graphql.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "reflect" "github.com/graph-gophers/graphql-go/errors" "github.com/graph-gophers/graphql-go/internal/common" @@ -37,13 +38,11 @@ func ParseSchema(schemaString string, resolver interface{}, opts ...SchemaOpt) ( return nil, err } - if resolver != nil { - r, err := resolvable.ApplyResolver(s.schema, resolver) - if err != nil { - return nil, err - } - s.res = r + r, err := resolvable.ApplyResolver(s.schema, resolver) + if err != nil { + return nil, err } + s.res = r return s, nil } @@ -156,7 +155,7 @@ func (s *Schema) Validate(queryString string) []*errors.QueryError { // without a resolver. If the context get cancelled, no further resolvers will be called and a // the context error will be returned as soon as possible (not immediately). func (s *Schema) Exec(ctx context.Context, queryString string, operationName string, variables map[string]interface{}) *Response { - if s.res == nil { + if s.res.Resolver == (reflect.Value{}) { panic("schema created without resolver, can not exec") } return s.exec(ctx, queryString, operationName, variables, s.res) diff --git a/graphql_test.go b/graphql_test.go index 84a6bf9e..9f2f3e63 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -3017,3 +3017,58 @@ func TestErrorPropagation(t *testing.T) { }, }) } + +func TestSchema_Exec_without_resolver(t *testing.T) { + t.Parallel() + + type args struct { + Query string + Schema string + } + type want struct { + Panic interface{} + } + testTable := []struct { + Name string + Args args + Want want + }{ + { + Name: "schema_without_resolver_errors", + Args: args{ + Query: ` + query { + hero { + id + name + friends { + name + } + } + } + `, + Schema: starwars.Schema, + }, + Want: want{Panic: "schema created without resolver, can not exec"}, + }, + } + + for _, tt := range testTable { + t.Run(tt.Name, func(t *testing.T) { + s := graphql.MustParseSchema(tt.Args.Schema, nil) + + defer func() { + r := recover() + if r == nil { + t.Fatal("expected query to panic") + } + if r != tt.Want.Panic { + t.Logf("got: %s", r) + t.Logf("want: %s", tt.Want.Panic) + t.Fail() + } + }() + _ = s.Exec(context.Background(), tt.Args.Query, "", map[string]interface{}{}) + }) + } +} diff --git a/internal/exec/resolvable/resolvable.go b/internal/exec/resolvable/resolvable.go index 87a2bd1f..e82d35e5 100644 --- a/internal/exec/resolvable/resolvable.go +++ b/internal/exec/resolvable/resolvable.go @@ -62,6 +62,10 @@ func (*List) isResolvable() {} func (*Scalar) isResolvable() {} func ApplyResolver(s *schema.Schema, resolver interface{}) (*Schema, error) { + if resolver == nil { + return &Schema{Meta: newMeta(s), Schema: *s}, nil + } + b := newBuilder(s) var query, mutation, subscription Resolvable diff --git a/introspection.go b/introspection.go index 7e515cf2..6877bcaf 100644 --- a/introspection.go +++ b/introspection.go @@ -16,6 +16,7 @@ func (s *Schema) Inspect() *introspection.Schema { // ToJSON encodes the schema in a JSON format used by tools like Relay. func (s *Schema) ToJSON() ([]byte, error) { result := s.exec(context.Background(), introspectionQuery, "", nil, &resolvable.Schema{ + Meta: s.res.Meta, Query: &resolvable.Object{}, Schema: *s.schema, }) diff --git a/introspection_test.go b/introspection_test.go new file mode 100644 index 00000000..63058a43 --- /dev/null +++ b/introspection_test.go @@ -0,0 +1,89 @@ +package graphql_test + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "testing" + + "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/example/social" + "github.com/graph-gophers/graphql-go/example/starwars" +) + +func TestSchema_ToJSON(t *testing.T) { + t.Parallel() + + type args struct { + Schema *graphql.Schema + } + type want struct { + JSON []byte + } + testTable := []struct { + Name string + Args args + Want want + }{ + { + Name: "Social Schema", + Args: args{Schema: graphql.MustParseSchema(social.Schema, &social.Resolver{}, graphql.UseFieldResolvers())}, + Want: want{JSON: mustReadFile("example/social/introspect.json")}, + }, + { + Name: "Star Wars Schema", + Args: args{Schema: graphql.MustParseSchema(starwars.Schema, &starwars.Resolver{})}, + Want: want{JSON: mustReadFile("example/starwars/introspect.json")}, + }, + { + Name: "Star Wars Schema without Resolver", + Args: args{Schema: graphql.MustParseSchema(starwars.Schema, nil)}, + Want: want{JSON: mustReadFile("example/starwars/introspect.json")}, + }, + } + + for _, tt := range testTable { + t.Run(tt.Name, func(t *testing.T) { + j, err := tt.Args.Schema.ToJSON() + if err != nil { + t.Fatalf("invalid schema %s", err.Error()) + } + + // Verify JSON to avoid red herring errors. + got, err := formatJSON(j) + if err != nil { + t.Fatalf("got: invalid JSON: %s", err) + } + want, err := formatJSON(tt.Want.JSON) + if err != nil { + t.Fatalf("want: invalid JSON: %s", err) + } + + if !bytes.Equal(got, want) { + t.Logf("got: %s", got) + t.Logf("want: %s", want) + t.Fail() + } + }) + } +} + +func formatJSON(data []byte) ([]byte, error) { + var v interface{} + if err := json.Unmarshal(data, &v); err != nil { + return nil, err + } + formatted, err := json.Marshal(v) + if err != nil { + return nil, err + } + return formatted, nil +} + +func mustReadFile(filename string) []byte { + b, err := ioutil.ReadFile(filename) + if err != nil { + panic(err) + } + return b +} diff --git a/subscription_test.go b/subscription_test.go index 62eb029d..7a7de6ed 100644 --- a/subscription_test.go +++ b/subscription_test.go @@ -263,7 +263,7 @@ func TestSchemaSubscribe(t *testing.T) { }, { Name: "schema_without_resolver_errors", - Schema: &graphql.Schema{}, + Schema: graphql.MustParseSchema(schema, nil), Query: ` subscription onHelloSaid { helloSaid { diff --git a/subscriptions.go b/subscriptions.go index ccb73ef3..2ee71e57 100644 --- a/subscriptions.go +++ b/subscriptions.go @@ -3,6 +3,7 @@ package graphql import ( "context" "errors" + "reflect" qerrors "github.com/graph-gophers/graphql-go/errors" "github.com/graph-gophers/graphql-go/internal/common" @@ -20,7 +21,7 @@ import ( // further resolvers will be called. The context error will be returned as soon // as possible (not immediately). func (s *Schema) Subscribe(ctx context.Context, queryString string, operationName string, variables map[string]interface{}) (<-chan interface{}, error) { - if s.res == nil { + if s.res.Resolver == (reflect.Value{}) { return nil, errors.New("schema created without resolver, can not subscribe") } return s.subscribe(ctx, queryString, operationName, variables, s.res), nil From 3572ff4612b7fda1d93577e660b34c5c4877a485 Mon Sep 17 00:00:00 2001 From: David Ackroyd Date: Thu, 9 May 2019 12:33:25 +1000 Subject: [PATCH 63/69] Fix Input Value Validation Validating Input values supplied via variable input are supplied with the correct input structure and values. This includes ensuring that enum embedded within input values are validated as well --- internal/validation/testdata/tests.json | 135 +++++++++++++++++++++--- internal/validation/validation.go | 16 ++- 2 files changed, 138 insertions(+), 13 deletions(-) diff --git a/internal/validation/testdata/tests.json b/internal/validation/testdata/tests.json index 69a2f146..46df80d3 100644 --- a/internal/validation/testdata/tests.json +++ b/internal/validation/testdata/tests.json @@ -1,6 +1,6 @@ { "schemas": [ - "schema {\n query: QueryRoot\n}\n\ndirective @onQuery on QUERY\n\ndirective @onMutation on MUTATION\n\ndirective @onSubscription on SUBSCRIPTION\n\ndirective @onField on FIELD\n\ndirective @onFragmentDefinition on FRAGMENT_DEFINITION\n\ndirective @onFragmentSpread on FRAGMENT_SPREAD\n\ndirective @onInlineFragment on INLINE_FRAGMENT\n\ndirective @onSchema on SCHEMA\n\ndirective @onScalar on SCALAR\n\ndirective @onObject on OBJECT\n\ndirective @onFieldDefinition on FIELD_DEFINITION\n\ndirective @onArgumentDefinition on ARGUMENT_DEFINITION\n\ndirective @onInterface on INTERFACE\n\ndirective @onUnion on UNION\n\ndirective @onEnum on ENUM\n\ndirective @onEnumValue on ENUM_VALUE\n\ndirective @onInputObject on INPUT_OBJECT\n\ndirective @onInputFieldDefinition on INPUT_FIELD_DEFINITION\n\ntype Alien implements Being & Intelligent {\n iq: Int\n name(surname: Boolean): String\n numEyes: Int\n}\n\nscalar Any\n\ninterface Being {\n name(surname: Boolean): String\n}\n\ninterface Canine {\n name(surname: Boolean): String\n}\n\ntype Cat implements Being & Pet {\n name(surname: Boolean): String\n nickname: String\n meows: Boolean\n meowVolume: Int\n furColor: FurColor\n}\n\nunion CatOrDog = Dog | Cat\n\ninput ComplexInput {\n requiredField: Boolean!\n intField: Int\n stringField: String\n booleanField: Boolean\n stringListField: [String]\n}\n\ntype ComplicatedArgs {\n intArgField(intArg: Int): String\n nonNullIntArgField(nonNullIntArg: Int!): String\n stringArgField(stringArg: String): String\n booleanArgField(booleanArg: Boolean): String\n enumArgField(enumArg: FurColor): String\n enumArrayArgField(enumArrayArg: [FurColor!]!): String\n floatArgField(floatArg: Float): String\n idArgField(idArg: ID): String\n stringListArgField(stringListArg: [String]): String\n stringListNonNullArgField(stringListNonNullArg: [String!]): String\n complexArgField(complexArg: ComplexInput): String\n multipleReqs(req1: Int!, req2: Int!): String\n multipleOpts(opt1: Int = 0, opt2: Int = 0): String\n multipleOptAndReq(req1: Int!, req2: Int!, opt1: Int = 0, opt2: Int = 0): String\n}\n\ntype Dog implements Being & Pet & Canine {\n name(surname: Boolean): String\n nickname: String\n barkVolume: Int\n barks: Boolean\n doesKnowCommand(dogCommand: DogCommand): Boolean\n isHousetrained(atOtherHomes: Boolean = true): Boolean\n isAtLocation(x: Int, y: Int): Boolean\n}\n\nenum DogCommand {\n SIT\n HEEL\n DOWN\n}\n\nunion DogOrHuman = Dog | Human\n\nenum FurColor {\n BROWN\n BLACK\n TAN\n SPOTTED\n NO_FUR\n UNKNOWN\n}\n\ntype Human implements Being & Intelligent {\n name(surname: Boolean): String\n pets: [Pet]\n relatives: [Human]\n iq: Int\n}\n\nunion HumanOrAlien = Human | Alien\n\ninterface Intelligent {\n iq: Int\n}\n\nscalar Invalid\n\ninterface Pet {\n name(surname: Boolean): String\n}\n\ntype QueryRoot {\n human(id: ID): Human\n alien: Alien\n dog: Dog\n cat: Cat\n pet: Pet\n catOrDog: CatOrDog\n dogOrHuman: DogOrHuman\n humanOrAlien: HumanOrAlien\n complicatedArgs: ComplicatedArgs\n invalidArg(arg: Invalid): String\n anyArg(arg: Any): String\n}\n", + "schema {\n query: QueryRoot\n}\n\ndirective @onQuery on QUERY\n\ndirective @onMutation on MUTATION\n\ndirective @onSubscription on SUBSCRIPTION\n\ndirective @onField on FIELD\n\ndirective @onFragmentDefinition on FRAGMENT_DEFINITION\n\ndirective @onFragmentSpread on FRAGMENT_SPREAD\n\ndirective @onInlineFragment on INLINE_FRAGMENT\n\ndirective @onSchema on SCHEMA\n\ndirective @onScalar on SCALAR\n\ndirective @onObject on OBJECT\n\ndirective @onFieldDefinition on FIELD_DEFINITION\n\ndirective @onArgumentDefinition on ARGUMENT_DEFINITION\n\ndirective @onInterface on INTERFACE\n\ndirective @onUnion on UNION\n\ndirective @onEnum on ENUM\n\ndirective @onEnumValue on ENUM_VALUE\n\ndirective @onInputObject on INPUT_OBJECT\n\ndirective @onInputFieldDefinition on INPUT_FIELD_DEFINITION\n\ntype Alien implements Being & Intelligent {\n iq: Int\n name(surname: Boolean): String\n numEyes: Int\n}\n\nscalar Any\n\ninterface Being {\n name(surname: Boolean): String\n}\n\ninterface Canine {\n name(surname: Boolean): String\n}\n\ntype Cat implements Being & Pet {\n name(surname: Boolean): String\n nickname: String\n meows: Boolean\n meowVolume: Int\n furColor: FurColor\n}\n\nunion CatOrDog = Dog | Cat\n\ninput ComplexInput {\n requiredField: Boolean!\n intField: Int\n stringField: String\n booleanField: Boolean\n stringListField: [String]\n enumField: FurColor\n nestedInput: SimpleInput}\n\ntype ComplicatedArgs {\n intArgField(intArg: Int): String\n nonNullIntArgField(nonNullIntArg: Int!): String\n stringArgField(stringArg: String): String\n booleanArgField(booleanArg: Boolean): String\n enumArgField(enumArg: FurColor): String\n enumArrayArgField(enumArrayArg: [FurColor!]!): String\n floatArgField(floatArg: Float): String\n idArgField(idArg: ID): String\n stringListArgField(stringListArg: [String]): String\n stringListNonNullArgField(stringListNonNullArg: [String!]): String\n complexArgField(complexArg: ComplexInput): String\n multipleReqs(req1: Int!, req2: Int!): String\n multipleOpts(opt1: Int = 0, opt2: Int = 0): String\n multipleOptAndReq(req1: Int!, req2: Int!, opt1: Int = 0, opt2: Int = 0): String\n}\n\ntype Dog implements Being & Pet & Canine {\n name(surname: Boolean): String\n nickname: String\n barkVolume: Int\n barks: Boolean\n doesKnowCommand(dogCommand: DogCommand): Boolean\n isHousetrained(atOtherHomes: Boolean = true): Boolean\n isAtLocation(x: Int, y: Int): Boolean\n}\n\nenum DogCommand {\n SIT\n HEEL\n DOWN\n}\n\nunion DogOrHuman = Dog | Human\n\nenum FurColor {\n BROWN\n BLACK\n TAN\n SPOTTED\n NO_FUR\n UNKNOWN\n}\n\ntype Human implements Being & Intelligent {\n name(surname: Boolean): String\n pets: [Pet]\n relatives: [Human]\n iq: Int\n}\n\nunion HumanOrAlien = Human | Alien\n\ninterface Intelligent {\n iq: Int\n}\n\nscalar Invalid\n\ninput SimpleInput {\n stringField: String\n stringListField: [String!]\n}\n\ninterface Pet {\n name(surname: Boolean): String\n}\n\ntype QueryRoot {\n human(id: ID): Human\n alien: Alien\n dog: Dog\n cat: Cat\n pet: Pet\n catOrDog: CatOrDog\n dogOrHuman: DogOrHuman\n humanOrAlien: HumanOrAlien\n complicatedArgs: ComplicatedArgs\n invalidArg(arg: Invalid): String\n anyArg(arg: Any): String\n}\n", "schema {\n query: QueryRoot\n}\n\ntype Connection {\n edges: [Edge]\n}\n\ntype Edge {\n node: Node\n}\n\ntype IntBox implements SomeBox {\n scalar: Int\n deepBox: IntBox\n unrelatedField: String\n listStringBox: [StringBox]\n stringBox: StringBox\n intBox: IntBox\n}\n\ntype Node {\n id: ID\n name: String\n}\n\ninterface NonNullStringBox1 {\n scalar: String!\n}\n\ntype NonNullStringBox1Impl implements SomeBox & NonNullStringBox1 {\n scalar: String!\n unrelatedField: String\n deepBox: SomeBox\n}\n\ninterface NonNullStringBox2 {\n scalar: String!\n}\n\ntype NonNullStringBox2Impl implements SomeBox & NonNullStringBox2 {\n scalar: String!\n unrelatedField: String\n deepBox: SomeBox\n}\n\ntype QueryRoot {\n someBox: SomeBox\n connection: Connection\n}\n\ninterface SomeBox {\n deepBox: SomeBox\n unrelatedField: String\n}\n\ntype StringBox implements SomeBox {\n scalar: String\n deepBox: StringBox\n unrelatedField: String\n listStringBox: [StringBox]\n stringBox: StringBox\n intBox: IntBox\n}\n", "type Foo {\n constructor: String\n}\n\ntype Query {\n foo: Foo\n}\n" ], @@ -1685,6 +1685,127 @@ }, "errors": [] }, + { + "name": "Validate: Variables have valid type/list with single value in variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($stringListArg: [String!])\n {\n stringListArgField(stringListArg: $stringListArg)\n }\n ", + "vars": { + "stringListArg": "single value" + }, + "errors": [] + }, + { + "name": "Validate: Variables have valid type/list with list value in variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($stringListArg: [String!])\n {\n stringListArgField(stringListArg: $stringListArg)\n }\n ", + "vars": { + "stringListArg": ["first value", "second value"] + }, + "errors": [] + }, + { + "name": "Validate: Variables have valid type/input type with invalid input in variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($complexVar: ComplexInput)\n {\n complicatedArgs {\n complexArgField(complexArg: $complexVar)\n }\n }\n ", + "vars": { + "complexVar": "not input" + }, + "errors": [ + { + "message": "Variable \"complexVar\" has invalid type string.\nExpected type \"ComplexInput\", found not input.", + "locations": [ + { + "line": 2, + "column": 19 + } + ] + } + ] + }, + { + "name": "Validate: Variables have valid type/input type with invalid enum constant in variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($complexVar: ComplexInput)\n {\n complicatedArgs {\n complexArgField(complexArg: $complexVar)\n }\n }\n ", + "vars": { + "complexVar": { + "requiredField": true, + "enumField": "RAINBOW" + } + }, + "errors": [ + { + "message": "Variable \"enumField\" has invalid value RAINBOW.\nExpected type \"FurColor\", found RAINBOW.", + "locations": [ + { + "line": 73, + "column": 3 + } + ] + } + ] + }, + { + "name": "Validate: Variables have valid type/input type with optional enum constant variable null", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($complexVar: ComplexInput)\n {\n complicatedArgs {\n complexArgField(complexArg: $complexVar)\n }\n }\n ", + "vars": { + "complexVar": { + "requiredField": true, + "enumField": null + } + }, + "errors": [] + }, + { + "name": "Validate: Variables have valid type/input type with nested input string from variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($complexVar: ComplexInput)\n {\n complicatedArgs {\n complexArgField(complexArg: $complexVar)\n }\n }\n ", + "vars": { + "complexVar": { + "requiredField": true, + "nestedInput": { + "stringField": "something" + } + } + }, + "errors": [] + }, + { + "name": "Validate: Variables have valid type/input type with nested input string list with single value from variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($complexVar: ComplexInput)\n {\n complicatedArgs {\n complexArgField(complexArg: $complexVar)\n }\n }\n ", + "vars": { + "complexVar": { + "requiredField": true, + "nestedInput": { + "stringListField": "something" + } + } + }, + "errors": [] + }, + { + "name": "Validate: Variables have valid type/input type with nested input string list from variable", + "rule": "VariablesOfCorrectType", + "schema": 0, + "query": "\n query Query($complexVar: ComplexInput)\n {\n complicatedArgs {\n complexArgField(complexArg: $complexVar)\n }\n }\n ", + "vars": { + "complexVar": { + "requiredField": true, + "nestedInput": { + "stringListField": ["first", "second"] + } + } + }, + "errors": [] + }, { "name": "Validate: Variables have valid type/number as enum", "rule": "VariablesOfCorrectType", @@ -1752,17 +1873,7 @@ "vars": { "colors": "BROWN" }, - "errors": [ - { - "message": "Variable \"colors\" has invalid type string.\nExpected type \"[FurColor!]\", found BROWN.", - "locations": [ - { - "line": 2, - "column": 19 - } - ] - } - ] + "errors": [] }, { "name": "Validate: Overlapping fields can be merged/unique fields", diff --git a/internal/validation/validation.go b/internal/validation/validation.go index dc95c0c9..94a9faf8 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -193,7 +193,8 @@ func validateValue(c *opContext, v *common.InputValue, val interface{}, t common } vv, ok := val.([]interface{}) if !ok { - c.addErr(v.Loc, "VariablesOfCorrectType", "Variable \"%s\" has invalid type %T.\nExpected type \"%s\", found %v.", v.Name.Name, val, t, val) + // Input coercion rules allow single items without wrapping array + validateValue(c, v, val, t.OfType) return } for _, elem := range vv { @@ -214,6 +215,19 @@ func validateValue(c *opContext, v *common.InputValue, val interface{}, t common } } c.addErr(v.Loc, "VariablesOfCorrectType", "Variable \"%s\" has invalid value %s.\nExpected type \"%s\", found %s.", v.Name.Name, e, t, e) + case *schema.InputObject: + if val == nil { + return + } + in, ok := val.(map[string]interface{}) + if !ok { + c.addErr(v.Loc, "VariablesOfCorrectType", "Variable \"%s\" has invalid type %T.\nExpected type \"%s\", found %s.", v.Name.Name, val, t, val) + return + } + for _, f := range t.Values { + fieldVal := in[f.Name.Name] + validateValue(c, f, fieldVal, f.Type) + } } } From 56aa86f29353be6b1ec931617ed4366116bfb797 Mon Sep 17 00:00:00 2001 From: gosp <141959@qq.com> Date: Mon, 10 Jun 2019 22:48:18 +0800 Subject: [PATCH 64/69] update opentracing-go version --- go.mod | 5 +---- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index fd80b0f2..088e9931 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,3 @@ module github.com/graph-gophers/graphql-go -require ( - github.com/opentracing/opentracing-go v1.0.2 - golang.org/x/net v0.0.0-20181220203305-927f97764cc3 -) +require github.com/opentracing/opentracing-go v1.1.0 diff --git a/go.sum b/go.sum index e55f3c23..71fd021b 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,2 @@ -github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg= -github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= From 35f2b75db27094712b248a0de85267dd8be75896 Mon Sep 17 00:00:00 2001 From: David Ackroyd Date: Wed, 24 Jul 2019 18:33:57 +1000 Subject: [PATCH 65/69] Query Caching Example Adding example app that allows responses to be cached based on hints added to the request context by resolvers. HTTP Cache-Control header is added using a customised version of the `relay` package HTTP handler implementation --- example/caching/cache/hint.go | 98 ++++++++++++++++++++++ example/caching/caching.go | 43 ++++++++++ example/caching/server/server.go | 139 +++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 example/caching/cache/hint.go create mode 100644 example/caching/caching.go create mode 100644 example/caching/server/server.go diff --git a/example/caching/cache/hint.go b/example/caching/cache/hint.go new file mode 100644 index 00000000..088aa8b5 --- /dev/null +++ b/example/caching/cache/hint.go @@ -0,0 +1,98 @@ +// Package cache implements caching of GraphQL requests by allowing resolvers to provide hints about their cacheability, +// which can be used by the transport handlers (e.g. HTTP) to provide caching indicators in the response. +package cache + +import ( + "context" + "fmt" + "time" +) + +type ctxKey string + +const ( + hintsKey ctxKey = "hints" +) + +type scope int + +// Cache control scopes. +const ( + ScopePublic scope = iota + ScopePrivate +) + +const ( + hintsBuffer = 20 +) + +// Hint defines a hint as to how long something should be cached for. +type Hint struct { + MaxAge *time.Duration + Scope scope +} + +// String resolves the HTTP Cache-Control value of the Hint. +func (h Hint) String() string { + var s string + switch h.Scope { + case ScopePublic: + s = "public" + case ScopePrivate: + s = "private" + } + return fmt.Sprintf("%s, max-age=%d", s, int(h.MaxAge.Seconds())) +} + +// TTL defines the cache duration. +func TTL(d time.Duration) *time.Duration { + return &d +} + +// AddHint applies a caching hint to the request context. +func AddHint(ctx context.Context, hint Hint) { + c := hints(ctx) + if c == nil { + return + } + c <- hint +} + +// Hintable extends the context with the ability to add cache hints. +func Hintable(ctx context.Context) (hintCtx context.Context, hint <-chan Hint, done func()) { + hints := make(chan Hint, hintsBuffer) + h := make(chan Hint) + go func() { + h <- resolve(hints) + }() + done = func() { + close(hints) + } + return context.WithValue(ctx, hintsKey, hints), h, done +} + +func hints(ctx context.Context) chan Hint { + h, ok := ctx.Value(hintsKey).(chan Hint) + if !ok { + return nil + } + return h +} + +func resolve(hints <-chan Hint) Hint { + var minAge *time.Duration + s := ScopePublic + for h := range hints { + if h.Scope == ScopePrivate { + s = h.Scope + } + if h.MaxAge != nil && (minAge == nil || *h.MaxAge < *minAge) { + minAge = h.MaxAge + } + } + if minAge == nil { + var noCache time.Duration + minAge = &noCache + } + return Hint{MaxAge: minAge, Scope: s} +} diff --git a/example/caching/caching.go b/example/caching/caching.go new file mode 100644 index 00000000..a173dead --- /dev/null +++ b/example/caching/caching.go @@ -0,0 +1,43 @@ +package caching + +import ( + "context" + "time" + + "github.com/graph-gophers/graphql-go/example/caching/cache" +) + +const Schema = ` + schema { + query: Query + } + + type Query { + hello(name: String!): String! + me: UserProfile! + } + + type UserProfile { + name: String! + } +` + +type Resolver struct{} + +func (r Resolver) Hello(ctx context.Context, args struct{ Name string }) string { + cache.AddHint(ctx, cache.Hint{MaxAge: cache.TTL(1 * time.Hour), Scope: cache.ScopePublic}) + return "Hello " + args.Name + "!" +} + +func (r Resolver) Me(ctx context.Context) *UserProfile { + cache.AddHint(ctx, cache.Hint{MaxAge: cache.TTL(1 * time.Minute), Scope: cache.ScopePrivate}) + return &UserProfile{name: "World"} +} + +type UserProfile struct { + name string +} + +func (p *UserProfile) Name() string { + return p.name +} diff --git a/example/caching/server/server.go b/example/caching/server/server.go new file mode 100644 index 00000000..4ede246f --- /dev/null +++ b/example/caching/server/server.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/example/caching" + "github.com/graph-gophers/graphql-go/example/caching/cache" +) + +var schema *graphql.Schema + +func init() { + schema = graphql.MustParseSchema(caching.Schema, &caching.Resolver{}) +} + +func main() { + http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(page) + })) + + http.Handle("/query", &Handler{Schema: schema}) + + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +type Handler struct { + Schema *graphql.Schema +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p, ok := h.parseRequest(w, r) + if !ok { + return + } + var response *graphql.Response + var hint *cache.Hint + if cacheable(r) { + ctx, hints, done := cache.Hintable(r.Context()) + response = h.Schema.Exec(ctx, p.Query, p.OperationName, p.Variables) + done() + v := <-hints + hint = &v + } else { + response = h.Schema.Exec(r.Context(), p.Query, p.OperationName, p.Variables) + } + responseJSON, err := json.Marshal(response) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if hint != nil { + w.Header().Set("Cache-Control", hint.String()) + } + w.Header().Set("Content-Type", "application/json") + w.Write(responseJSON) +} + +func (h *Handler) parseRequest(w http.ResponseWriter, r *http.Request) (params, bool) { + var p params + switch r.Method { + case http.MethodGet: + q := r.URL.Query() + if p.Query = q.Get("query"); p.Query == "" { + http.Error(w, "A non-empty 'query' parameter is required", http.StatusBadRequest) + return params{}, false + } + p.OperationName = q.Get("operationName") + if vars := q.Get("variables"); vars != "" { + if err := json.Unmarshal([]byte(vars), &p.Variables); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return params{}, false + } + } + return p, true + case http.MethodPost: + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return params{}, false + } + return p, true + default: + http.Error(w, fmt.Sprintf("unsupported HTTP method: %s", r.Method), http.StatusMethodNotAllowed) + return params{}, false + } +} + +func cacheable(r *http.Request) bool { + return r.Method == http.MethodGet +} + +type params struct { + Query string `json:"query"` + OperationName string `json:"operationName"` + Variables map[string]interface{} `json:"variables"` +} + +var page = []byte(` + + + + + + + + + + + +
Loading...
+ + + +`) From a91d95307f51c0ec3085adc31e1c1111f1dc4f97 Mon Sep 17 00:00:00 2001 From: Ajay Kumar Date: Thu, 25 Oct 2018 20:27:11 +0530 Subject: [PATCH 66/69] fix response feature in progress --- graphql.go | 13 +++++ internal/exec/exec.go | 117 +++++++++++++++++++++++++++++++++++----- internal/query/query.go | 3 ++ 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/graphql.go b/graphql.go index 8d637ad1..d368a533 100644 --- a/graphql.go +++ b/graphql.go @@ -150,6 +150,19 @@ func (s *Schema) Validate(queryString string) []*errors.QueryError { return validation.Validate(s.schema, doc, nil, s.maxDepth) } +func SetFixedResponse(m map[string]interface{}) { + if m != nil { + if t, ok := m["query"]; ok { + if v, ok := t.(map[string]interface{}); ok { + query.FixedResponseQuery = v + } + } else if t, ok := m["mutation"]; ok { + if v, ok := t.(map[string]interface{}); ok { + query.FixedResponseMutation = v + } + } + } +} // Exec executes the given query with the schema's resolver. It panics if the schema was created // without a resolver. If the context get cancelled, no further resolvers will be called and a diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 2fd7776d..3c3e50f8 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -46,7 +46,14 @@ func (r *Request) Execute(ctx context.Context, s *resolvable.Schema, op *query.O func() { defer r.handlePanic(ctx) sels := selected.ApplyOperation(&r.Request, s, op) - r.execSelections(ctx, sels, nil, s, s.Resolver, &out, op.Type == query.Mutation) + if op.Type == query.Mutation { + r.execSelections(ctx, sels, nil, s.Resolver, &out, true, query.FixedResponseMutation) + } else if op.Type == query.Query { + r.execSelections(ctx, sels, nil, s.Resolver, &out, false, query.FixedResponseQuery) + } else { + panic(fmt.Sprintf("operation %s not supported", op.Type)) + } + }() if err := ctx.Err(); err != nil { @@ -67,7 +74,7 @@ func resolvedToNull(b *bytes.Buffer) bool { return bytes.Equal(b.Bytes(), []byte("null")) } -func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, s *resolvable.Schema, resolver reflect.Value, out *bytes.Buffer, serially bool) { +func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, serially bool, fixedRes map[string]interface{}) { async := !serially && selected.HasAsyncSel(sels) var fields []*fieldToExec @@ -81,7 +88,7 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, defer wg.Done() defer r.handlePanic(ctx) f.out = new(bytes.Buffer) - execFieldSelection(ctx, r, s, f, &pathSegment{path, f.field.Alias}, true) + execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, true, fixedRes) }(f) } wg.Wait() @@ -110,7 +117,12 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, out.WriteString(f.field.Alias) out.WriteByte('"') out.WriteByte(':') - out.Write(f.out.Bytes()) + if async { + out.Write(f.out.Bytes()) + continue + } + f.out = out + execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, false, fixedRes) } out.WriteByte('}') } @@ -152,6 +164,7 @@ func typeOf(tf *selected.TypenameField, resolver reflect.Value) string { if len(tf.TypeAssertions) == 0 { return tf.Name } + for name, a := range tf.TypeAssertions { out := resolver.Method(a.MethodIndex).Call(nil) if out[1].Bool() { @@ -161,7 +174,7 @@ func typeOf(tf *selected.TypenameField, resolver reflect.Value) string { return "" } -func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f *fieldToExec, path *pathSegment, applyLimiter bool) { +func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *pathSegment, applyLimiter bool, fixedRes map[string]interface{}) { if applyLimiter { r.Limiter <- struct{}{} } @@ -183,11 +196,24 @@ func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f } }() + if temp, ok := fixedRes[f.field.Name]; ok { + if v, ok := temp.(map[string]interface{}); ok { + fixedRes = v + } else { + result = reflect.ValueOf(temp) + } + return nil + } + if f.field.FixedResult.IsValid() { result = f.field.FixedResult return nil } + if !f.resolver.IsValid() { + return nil + } + if err := traceCtx.Err(); err != nil { return errors.Errorf("%s", err) // don't execute any more resolvers if context got cancelled } @@ -199,6 +225,7 @@ func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f if f.field.ArgsPacker != nil { in = append(in, f.field.PackedArgs) } + callOut := f.resolver.Method(f.field.MethodIndex).Call(in) result = callOut[0] if f.field.HasError && !callOut[1].IsNil() { @@ -230,16 +257,15 @@ func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f f.out.WriteString("null") return } - - r.execSelectionSet(traceCtx, f.sels, f.field.Type, path, s, result, f.out) + r.execSelectionSet(traceCtx, f.sels, f.field.Type, path, result, f.out, fixedRes) } -func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selection, typ common.Type, path *pathSegment, s *resolvable.Schema, resolver reflect.Value, out *bytes.Buffer) { +func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selection, typ common.Type, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, fixedRes map[string]interface{}) { t, nonNull := unwrapNonNull(typ) switch t := t.(type) { case *schema.Object, *schema.Interface, *schema.Union: // a reflect.Value of a nil interface will show up as an Invalid value - if resolver.Kind() == reflect.Invalid || ((resolver.Kind() == reflect.Ptr || resolver.Kind() == reflect.Interface) && resolver.IsNil()) { + if resolver.IsValid() && (resolver.Kind() == reflect.Invalid || ((resolver.Kind() == reflect.Ptr || resolver.Kind() == reflect.Interface) && resolver.IsNil())) { // If a field of a non-null type resolves to null (either because the // function to resolve the field returned null or because an error occurred), // add an error to the "errors" list in the response. @@ -251,13 +277,19 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio out.WriteString("null") return } - - r.execSelections(ctx, sels, path, s, resolver, out, false) + if resolver.IsValid() { + //checking for fixedValue object + if v, ok := resolver.Interface().(map[string]interface{}); ok { + r.execSelections(ctx, sels, path, resolver, out, false, v) + return + } + } + r.execSelections(ctx, sels, path, resolver, out, false, fixedRes) return } if !nonNull { - if resolver.IsNil() { + if !resolver.IsValid() || resolver.IsNil() { out.WriteString("null") return } @@ -266,10 +298,67 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio switch t := t.(type) { case *common.List: - r.execList(ctx, sels, t, path, s, resolver, out) + if !resolver.IsValid() { + out.WriteByte('[') + out.WriteByte(']') + return + } + + l := resolver.Len() + + if selected.HasAsyncSel(sels) { + var wg sync.WaitGroup + wg.Add(l) + entryouts := make([]bytes.Buffer, l) + for i := 0; i < l; i++ { + go func(i int) { + defer wg.Done() + defer r.handlePanic(ctx) + r.execSelectionSet(ctx, sels, t.OfType, &pathSegment{path, i}, resolver.Index(i), &entryouts[i], fixedRes) + }(i) + } + wg.Wait() + + out.WriteByte('[') + for i, entryout := range entryouts { + if i > 0 { + out.WriteByte(',') + } + out.Write(entryout.Bytes()) + } + out.WriteByte(']') + return + } + + out.WriteByte('[') + for i := 0; i < l; i++ { + if i > 0 { + out.WriteByte(',') + } + r.execSelectionSet(ctx, sels, t.OfType, &pathSegment{path, i}, resolver.Index(i), out, fixedRes) + } + out.WriteByte(']') case *schema.Scalar: - v := resolver.Interface() + var v interface{} + var ok bool + if v, ok = fixedRes[t.Name]; !ok { + if resolver.IsValid() { + v = resolver.Interface() + } else { + switch t.Name { + case "Boolean": + v = false + case "Int": + v = 0 + case "Float": + v = 0.0 + default: + v = "" //"String", "ID" + } + } + } + data, err := json.Marshal(v) if err != nil { panic(errors.Errorf("could not marshal %v: %s", v, err)) diff --git a/internal/query/query.go b/internal/query/query.go index 95585795..07ced185 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -8,6 +8,9 @@ import ( "github.com/tokopedia/graphql-go/internal/common" ) +var FixedResponseQuery map[string]interface{} +var FixedResponseMutation map[string]interface{} + type Document struct { Operations OperationList Fragments FragmentList From 527dd2f2d508e671bca78c50fe5e7eb41841b16e Mon Sep 17 00:00:00 2001 From: Ajay Kumar Date: Wed, 31 Oct 2018 12:33:53 +0530 Subject: [PATCH 67/69] fix response fixed --- internal/exec/exec.go | 139 ++++++++++++++++++++++++++++++------------ 1 file changed, 99 insertions(+), 40 deletions(-) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 3c3e50f8..8809730d 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -46,10 +46,12 @@ func (r *Request) Execute(ctx context.Context, s *resolvable.Schema, op *query.O func() { defer r.handlePanic(ctx) sels := selected.ApplyOperation(&r.Request, s, op) + //var for getting result from fixedResp, is being initialized with false value + var fromFixedResp bool if op.Type == query.Mutation { - r.execSelections(ctx, sels, nil, s.Resolver, &out, true, query.FixedResponseMutation) + r.execSelections(ctx, sels, nil, s.Resolver, &out, true, query.FixedResponseMutation, &fromFixedResp) } else if op.Type == query.Query { - r.execSelections(ctx, sels, nil, s.Resolver, &out, false, query.FixedResponseQuery) + r.execSelections(ctx, sels, nil, s.Resolver, &out, false, query.FixedResponseQuery, &fromFixedResp) } else { panic(fmt.Sprintf("operation %s not supported", op.Type)) } @@ -74,11 +76,11 @@ func resolvedToNull(b *bytes.Buffer) bool { return bytes.Equal(b.Bytes(), []byte("null")) } -func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, serially bool, fixedRes map[string]interface{}) { +func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, serially bool, fixedRes interface{}, fromFixedResp *bool) { async := !serially && selected.HasAsyncSel(sels) var fields []*fieldToExec - collectFieldsToResolve(sels, s, resolver, &fields, make(map[string]*fieldToExec)) + collectFieldsToResolve(sels, resolver, &fields, make(map[string]*fieldToExec), fromFixedResp) if async { var wg sync.WaitGroup @@ -88,7 +90,7 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, defer wg.Done() defer r.handlePanic(ctx) f.out = new(bytes.Buffer) - execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, true, fixedRes) + execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, true, fixedRes, fromFixedResp) }(f) } wg.Wait() @@ -122,12 +124,12 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, continue } f.out = out - execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, false, fixedRes) + execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, false, fixedRes, fromFixedResp) } out.WriteByte('}') } -func collectFieldsToResolve(sels []selected.Selection, s *resolvable.Schema, resolver reflect.Value, fields *[]*fieldToExec, fieldByAlias map[string]*fieldToExec) { +func collectFieldsToResolve(sels []selected.Selection, resolver reflect.Value, fields *[]*fieldToExec, fieldByAlias map[string]*fieldToExec, fromFixedResp *bool) { for _, sel := range sels { switch sel := sel.(type) { case *selected.SchemaField: @@ -148,11 +150,15 @@ func collectFieldsToResolve(sels []selected.Selection, s *resolvable.Schema, res *fields = append(*fields, &fieldToExec{field: sf, resolver: resolver}) case *selected.TypeAssertion: + //TODO: may need to revisit again + if *fromFixedResp { + continue + } out := resolver.Method(sel.MethodIndex).Call(nil) if !out[1].Bool() { continue } - collectFieldsToResolve(sel.Sels, s, out[0], fields, fieldByAlias) + collectFieldsToResolve(sel.Sels, out[0], fields, fieldByAlias, fromFixedResp) default: panic("unreachable") @@ -174,7 +180,7 @@ func typeOf(tf *selected.TypenameField, resolver reflect.Value) string { return "" } -func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *pathSegment, applyLimiter bool, fixedRes map[string]interface{}) { +func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *pathSegment, applyLimiter bool, fixedRes interface{}, fromFixedResp *bool) { if applyLimiter { r.Limiter <- struct{}{} } @@ -196,14 +202,34 @@ func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *p } }() - if temp, ok := fixedRes[f.field.Name]; ok { - if v, ok := temp.(map[string]interface{}); ok { - fixedRes = v - } else { - result = reflect.ValueOf(temp) + if *fromFixedResp { + //>=2 level depth + //if fixResp exist then use that, else just bypass and return the default values + if fixedRes != nil { + if respMap, ok := fixedRes.(map[string]interface{}); ok { + if item, ok := respMap[f.field.Name]; ok { + fixedRes = item + return nil + } + } } + fixedRes = nil return nil } + if fixedRes != nil { + //this will be called only first time + if respMap, ok := fixedRes.(map[string]interface{}); ok { + if item, ok := respMap[f.field.Name]; ok { + fixedRes = item + *fromFixedResp = true + return nil + } + } else { + fixedRes = nil + //as fixedResp first time itself is not valid, bypassing from here to return the default responses + return nil + } + } if f.field.FixedResult.IsValid() { result = f.field.FixedResult @@ -257,18 +283,27 @@ func execFieldSelection(ctx context.Context, r *Request, f *fieldToExec, path *p f.out.WriteString("null") return } - r.execSelectionSet(traceCtx, f.sels, f.field.Type, path, result, f.out, fixedRes) + r.execSelectionSet(traceCtx, f.sels, f.field.Type, path, result, f.out, fixedRes, fromFixedResp) } -func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selection, typ common.Type, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, fixedRes map[string]interface{}) { +func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selection, typ common.Type, path *pathSegment, resolver reflect.Value, out *bytes.Buffer, fixedRes interface{}, fromFixedResp *bool) { t, nonNull := unwrapNonNull(typ) switch t := t.(type) { case *schema.Object, *schema.Interface, *schema.Union: - // a reflect.Value of a nil interface will show up as an Invalid value - if resolver.IsValid() && (resolver.Kind() == reflect.Invalid || ((resolver.Kind() == reflect.Ptr || resolver.Kind() == reflect.Interface) && resolver.IsNil())) { - // If a field of a non-null type resolves to null (either because the - // function to resolve the field returned null or because an error occurred), - // add an error to the "errors" list in the response. + if *fromFixedResp { + if fixedRes != nil { + if _, ok := fixedRes.(map[string]interface{}); !ok { + //TODO:print error for invalid input + if !nonNull { + out.WriteString("null") + return + } else { + //if nonNull and fixedResp is invalid then bypassing to return default responses + fixedRes = nil + } + } + } + } else if resolver.Kind() == reflect.Invalid || ((resolver.Kind() == reflect.Ptr || resolver.Kind() == reflect.Interface) && resolver.IsNil()) { if nonNull { err := errors.Errorf("graphql: got nil for non-null %q", t) err.Path = path.toSlice() @@ -277,30 +312,52 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio out.WriteString("null") return } - if resolver.IsValid() { - //checking for fixedValue object - if v, ok := resolver.Interface().(map[string]interface{}); ok { - r.execSelections(ctx, sels, path, resolver, out, false, v) - return - } - } - r.execSelections(ctx, sels, path, resolver, out, false, fixedRes) + r.execSelections(ctx, sels, path, resolver, out, false, fixedRes, fromFixedResp) return } if !nonNull { - if !resolver.IsValid() || resolver.IsNil() { - out.WriteString("null") - return + if *fromFixedResp { + if fixedRes == nil { + out.WriteString("null") + return + } + } else { + if resolver.IsNil() { + out.WriteString("null") + return + } + if resolver.Kind() != reflect.Slice { + resolver = resolver.Elem() + } } - resolver = resolver.Elem() + } switch t := t.(type) { case *common.List: - if !resolver.IsValid() { + if *fromFixedResp { + if fixedRes != nil { + if sliceResp, ok := fixedRes.([]interface{}); ok { + //setting resolver invalid, will happen for 1st time + var resolver reflect.Value + + out.WriteByte('[') + for i := 0; i < len(sliceResp); i++ { + if i > 0 { + out.WriteByte(',') + } + r.execSelectionSet(ctx, sels, t.OfType, &pathSegment{path, i}, resolver, out, sliceResp[i], fromFixedResp) + } + out.WriteByte(']') + return + } + } + //as fixed resp values are invalid, just bypassing everything + fmt.Printf("[fixedResp:common.List]got invalid fixedResp %v for list\n", fixedRes) out.WriteByte('[') out.WriteByte(']') + fixedRes = nil return } @@ -314,7 +371,7 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio go func(i int) { defer wg.Done() defer r.handlePanic(ctx) - r.execSelectionSet(ctx, sels, t.OfType, &pathSegment{path, i}, resolver.Index(i), &entryouts[i], fixedRes) + r.execSelectionSet(ctx, sels, t.OfType, &pathSegment{path, i}, resolver.Index(i), &entryouts[i], fixedRes, fromFixedResp) }(i) } wg.Wait() @@ -335,16 +392,16 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio if i > 0 { out.WriteByte(',') } - r.execSelectionSet(ctx, sels, t.OfType, &pathSegment{path, i}, resolver.Index(i), out, fixedRes) + r.execSelectionSet(ctx, sels, t.OfType, &pathSegment{path, i}, resolver.Index(i), out, fixedRes, fromFixedResp) } out.WriteByte(']') case *schema.Scalar: var v interface{} - var ok bool - if v, ok = fixedRes[t.Name]; !ok { - if resolver.IsValid() { - v = resolver.Interface() + + if *fromFixedResp { + if fixedRes != nil { + v = fixedRes } else { switch t.Name { case "Boolean": @@ -357,6 +414,8 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio v = "" //"String", "ID" } } + } else { + v = resolver.Interface() } data, err := json.Marshal(v) From 7a5f606f69276267c5fe344d174c6a1791e4a71f Mon Sep 17 00:00:00 2001 From: Aditi Singh Date: Mon, 19 Nov 2018 17:23:15 +0530 Subject: [PATCH 68/69] set default response for mutation fixed --- graphql.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphql.go b/graphql.go index d368a533..3b90e1b5 100644 --- a/graphql.go +++ b/graphql.go @@ -156,7 +156,8 @@ func SetFixedResponse(m map[string]interface{}) { if v, ok := t.(map[string]interface{}); ok { query.FixedResponseQuery = v } - } else if t, ok := m["mutation"]; ok { + } + if t, ok := m["mutation"]; ok { if v, ok := t.(map[string]interface{}); ok { query.FixedResponseMutation = v } From d69a0b9457bac4558e055fc4bf2a54adb7980862 Mon Sep 17 00:00:00 2001 From: abhif22 Date: Tue, 20 Aug 2019 23:56:44 +0530 Subject: [PATCH 69/69] Updated graph-gophers to tokopedia in import paths --- example/caching/caching.go | 2 +- example/caching/server/server.go | 6 ++--- example/social/server/server.go | 6 ++--- example/social/social.go | 2 +- gqltesting/subscriptions.go | 4 +-- graphql_test.go | 8 +++--- internal/exec/exec.go | 45 ++------------------------------ internal/exec/subscribe.go | 21 ++++++++------- introspection_test.go | 6 ++--- subscription_test.go | 6 ++--- subscriptions.go | 16 ++++++------ 11 files changed, 42 insertions(+), 80 deletions(-) diff --git a/example/caching/caching.go b/example/caching/caching.go index a173dead..892c6586 100644 --- a/example/caching/caching.go +++ b/example/caching/caching.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/graph-gophers/graphql-go/example/caching/cache" + "github.com/tokopedia/graphql-go/example/caching/cache" ) const Schema = ` diff --git a/example/caching/server/server.go b/example/caching/server/server.go index 4ede246f..3514b10d 100644 --- a/example/caching/server/server.go +++ b/example/caching/server/server.go @@ -6,9 +6,9 @@ import ( "log" "net/http" - "github.com/graph-gophers/graphql-go" - "github.com/graph-gophers/graphql-go/example/caching" - "github.com/graph-gophers/graphql-go/example/caching/cache" + "github.com/tokopedia/graphql-go" + "github.com/tokopedia/graphql-go/example/caching" + "github.com/tokopedia/graphql-go/example/caching/cache" ) var schema *graphql.Schema diff --git a/example/social/server/server.go b/example/social/server/server.go index 6bfde72b..c3697972 100644 --- a/example/social/server/server.go +++ b/example/social/server/server.go @@ -4,9 +4,9 @@ import ( "log" "net/http" - "github.com/graph-gophers/graphql-go" - "github.com/graph-gophers/graphql-go/example/social" - "github.com/graph-gophers/graphql-go/relay" + "github.com/tokopedia/graphql-go" + "github.com/tokopedia/graphql-go/example/social" + "github.com/tokopedia/graphql-go/relay" ) func main() { diff --git a/example/social/social.go b/example/social/social.go index 67774207..4182dff5 100644 --- a/example/social/social.go +++ b/example/social/social.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/graph-gophers/graphql-go" + "github.com/tokopedia/graphql-go" ) const Schema = ` diff --git a/gqltesting/subscriptions.go b/gqltesting/subscriptions.go index 7a1cd0d1..b44e1acc 100644 --- a/gqltesting/subscriptions.go +++ b/gqltesting/subscriptions.go @@ -7,8 +7,8 @@ import ( "strconv" "testing" - graphql "github.com/graph-gophers/graphql-go" - "github.com/graph-gophers/graphql-go/errors" + graphql "github.com/tokopedia/graphql-go" + "github.com/tokopedia/graphql-go/errors" ) // TestResponse models the expected response diff --git a/graphql_test.go b/graphql_test.go index 450e3eb7..5faebd4f 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" - "github.com/graph-gophers/graphql-go" - gqlerrors "github.com/graph-gophers/graphql-go/errors" - "github.com/graph-gophers/graphql-go/example/starwars" - "github.com/graph-gophers/graphql-go/gqltesting" + "github.com/tokopedia/graphql-go" + gqlerrors "github.com/tokopedia/graphql-go/errors" + "github.com/tokopedia/graphql-go/example/starwars" + "github.com/tokopedia/graphql-go/gqltesting" ) type helloWorldResolver1 struct{} diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 8809730d..9aea2c2b 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -97,7 +97,7 @@ func (r *Request) execSelections(ctx context.Context, sels []selected.Selection, } else { for _, f := range fields { f.out = new(bytes.Buffer) - execFieldSelection(ctx, r, s, f, &pathSegment{path, f.field.Alias}, true) + execFieldSelection(ctx, r, f, &pathSegment{path, f.field.Alias}, true, fixedRes, fromFixedResp) } } @@ -143,7 +143,7 @@ func collectFieldsToResolve(sels []selected.Selection, resolver reflect.Value, f case *selected.TypenameField: sf := &selected.SchemaField{ - Field: s.Meta.FieldTypename, + // Field: resolvable.Meta, Alias: sel.Alias, FixedResult: reflect.ValueOf(typeOf(sel, resolver)), } @@ -453,47 +453,6 @@ func (r *Request) execSelectionSet(ctx context.Context, sels []selected.Selectio } } -func (r *Request) execList(ctx context.Context, sels []selected.Selection, typ *common.List, path *pathSegment, s *resolvable.Schema, resolver reflect.Value, out *bytes.Buffer) { - l := resolver.Len() - entryouts := make([]bytes.Buffer, l) - - if selected.HasAsyncSel(sels) { - var wg sync.WaitGroup - wg.Add(l) - for i := 0; i < l; i++ { - go func(i int) { - defer wg.Done() - defer r.handlePanic(ctx) - r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, s, resolver.Index(i), &entryouts[i]) - }(i) - } - wg.Wait() - } else { - for i := 0; i < l; i++ { - r.execSelectionSet(ctx, sels, typ.OfType, &pathSegment{path, i}, s, resolver.Index(i), &entryouts[i]) - } - } - - _, listOfNonNull := typ.OfType.(*common.NonNull) - - out.WriteByte('[') - for i, entryout := range entryouts { - // If the list wraps a non-null type and one of the list elements - // resolves to null, then the entire list resolves to null. - if listOfNonNull && resolvedToNull(&entryout) { - out.Reset() - out.WriteString("null") - return - } - - if i > 0 { - out.WriteByte(',') - } - out.Write(entryout.Bytes()) - } - out.WriteByte(']') -} - func unwrapNonNull(t common.Type) (common.Type, bool) { if nn, ok := t.(*common.NonNull); ok { return nn.OfType, true diff --git a/internal/exec/subscribe.go b/internal/exec/subscribe.go index 6c7ea1a0..94945780 100644 --- a/internal/exec/subscribe.go +++ b/internal/exec/subscribe.go @@ -8,11 +8,11 @@ import ( "reflect" "time" - "github.com/graph-gophers/graphql-go/errors" - "github.com/graph-gophers/graphql-go/internal/common" - "github.com/graph-gophers/graphql-go/internal/exec/resolvable" - "github.com/graph-gophers/graphql-go/internal/exec/selected" - "github.com/graph-gophers/graphql-go/internal/query" + "github.com/tokopedia/graphql-go/errors" + "github.com/tokopedia/graphql-go/internal/common" + "github.com/tokopedia/graphql-go/internal/exec/resolvable" + "github.com/tokopedia/graphql-go/internal/exec/selected" + "github.com/tokopedia/graphql-go/internal/query" ) type Response struct { @@ -24,13 +24,13 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *query var result reflect.Value var f *fieldToExec var err *errors.QueryError + var fromFixedResponse bool func() { defer r.handlePanic(ctx) sels := selected.ApplyOperation(&r.Request, s, op) var fields []*fieldToExec - collectFieldsToResolve(sels, s, s.Resolver, &fields, make(map[string]*fieldToExec)) - + collectFieldsToResolve(sels, s.Resolver, &fields, make(map[string]*fieldToExec), &fromFixedResponse) // TODO: move this check into validation.Validate if len(fields) != 1 { err = errors.Errorf("%s", "can subscribe to at most one subscription at a time") @@ -118,9 +118,12 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *query // resolve response func() { defer subR.handlePanic(subCtx) - + var ( + fixedRes interface{} + fromFixedResp bool + ) var buf bytes.Buffer - subR.execSelectionSet(subCtx, f.sels, f.field.Type, &pathSegment{nil, f.field.Alias}, s, resp, &buf) + subR.execSelectionSet(subCtx, f.sels, f.field.Type, &pathSegment{nil, f.field.Alias}, resp, &buf, fixedRes, &fromFixedResp) propagateChildError := false if _, nonNullChild := f.field.Type.(*common.NonNull); nonNullChild && resolvedToNull(&buf) { diff --git a/introspection_test.go b/introspection_test.go index 63058a43..e66e0fdc 100644 --- a/introspection_test.go +++ b/introspection_test.go @@ -6,9 +6,9 @@ import ( "io/ioutil" "testing" - "github.com/graph-gophers/graphql-go" - "github.com/graph-gophers/graphql-go/example/social" - "github.com/graph-gophers/graphql-go/example/starwars" + "github.com/tokopedia/graphql-go" + "github.com/tokopedia/graphql-go/example/social" + "github.com/tokopedia/graphql-go/example/starwars" ) func TestSchema_ToJSON(t *testing.T) { diff --git a/subscription_test.go b/subscription_test.go index 7a7de6ed..f3ebbbd8 100644 --- a/subscription_test.go +++ b/subscription_test.go @@ -6,9 +6,9 @@ import ( "errors" "testing" - graphql "github.com/graph-gophers/graphql-go" - qerrors "github.com/graph-gophers/graphql-go/errors" - "github.com/graph-gophers/graphql-go/gqltesting" + graphql "github.com/tokopedia/graphql-go" + qerrors "github.com/tokopedia/graphql-go/errors" + "github.com/tokopedia/graphql-go/gqltesting" ) type rootResolver struct { diff --git a/subscriptions.go b/subscriptions.go index 2ee71e57..bf773343 100644 --- a/subscriptions.go +++ b/subscriptions.go @@ -5,14 +5,14 @@ import ( "errors" "reflect" - qerrors "github.com/graph-gophers/graphql-go/errors" - "github.com/graph-gophers/graphql-go/internal/common" - "github.com/graph-gophers/graphql-go/internal/exec" - "github.com/graph-gophers/graphql-go/internal/exec/resolvable" - "github.com/graph-gophers/graphql-go/internal/exec/selected" - "github.com/graph-gophers/graphql-go/internal/query" - "github.com/graph-gophers/graphql-go/internal/validation" - "github.com/graph-gophers/graphql-go/introspection" + qerrors "github.com/tokopedia/graphql-go/errors" + "github.com/tokopedia/graphql-go/internal/common" + "github.com/tokopedia/graphql-go/internal/exec" + "github.com/tokopedia/graphql-go/internal/exec/resolvable" + "github.com/tokopedia/graphql-go/internal/exec/selected" + "github.com/tokopedia/graphql-go/internal/query" + "github.com/tokopedia/graphql-go/internal/validation" + "github.com/tokopedia/graphql-go/introspection" ) // Subscribe returns a response channel for the given subscription with the schema's