diff --git a/README.md b/README.md index b3e4eb84c..069617743 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,8 @@ schema := graphql.MustParseSchema(sdl, &RootResolver{}, nil) - `DisableFieldSelections()` disables capturing child field selections used by helper APIs (see below). - `DisableMemoryPooling()` disables internal execution-path memory pooling. Pooling is enabled by default; this option is intended for diagnostics and benchmark comparisons. - `OverlapValidationLimit(n int)` sets a hard cap on examined overlap pairs during validation; exceeding it emits `OverlapValidationLimitExceeded` error. +- `DirectiveVisitors(...)` registers directive visitors to inspect and validate directives during pre-execution analysis. Visitors can implement authorization, cost analysis, analytics, or custom logic. Users implement the `DirectiveVisitor` interface with `Name() string` and `Visit(ctx context.Context, d DirectiveContext) error`. Call `d.DecodeArgs(&args)` inside `Visit` to parse directive arguments, and `d.FieldArg(name, &value)` to read field arguments by name. Visitor names must be unique per schema. +- `PreExecHook(...)` registers a callback that runs after validation and directive pre-execution checks, but before resolver execution. Returning an error aborts execution. ### Field Selection Inspection Helpers @@ -283,4 +285,9 @@ type Tracer interface { } ``` +### Directive Visitors + +Directive visitors provide a generic, Go-idiomatic way to inspect and validate GraphQL directives during pre-execution analysis. Visitors can directly reject execution by returning an error. +Each directive name can have at most one registered visitor per schema. + ### [Examples](https://github.com/graph-gophers/graphql-go/wiki/Examples) diff --git a/directives.go b/directives.go new file mode 100644 index 000000000..f909619ef --- /dev/null +++ b/directives.go @@ -0,0 +1,299 @@ +package graphql + +import ( + "context" + "fmt" + "reflect" + + "github.com/graph-gophers/graphql-go/ast" + "github.com/graph-gophers/graphql-go/errors" + "github.com/graph-gophers/graphql-go/internal/exec/packer" + "github.com/graph-gophers/graphql-go/internal/exec/selected" +) + +type directiveArgsCacheKey struct { + def *ast.DirectiveDefinition + typ reflect.Type +} + +type DirectiveContext struct { + owner *Schema + schema *ast.Schema + directive *ast.Directive + args map[string]any + fieldArgs map[string]any + decodedArgs map[reflect.Type]reflect.Value +} + +func (c DirectiveContext) DecodeArgs(dst any) error { + if c.schema == nil || c.directive == nil { + return fmt.Errorf("directive context is missing schema metadata") + } + def := c.schema.Directives[c.directive.Name.Name] + if def == nil { + return fmt.Errorf("directive %q is not defined in the schema", c.directive.Name.Name) + } + if dst == nil { + return fmt.Errorf("destination must be a non-nil pointer") + } + typ := reflect.TypeOf(dst) + if typ.Kind() != reflect.Pointer { + return fmt.Errorf("destination must be a pointer, got %s", typ) + } + rv := reflect.ValueOf(dst) + if rv.IsNil() { + return fmt.Errorf("destination must be a non-nil pointer") + } + var ( + sp *packer.StructPacker + err error + ) + if c.owner != nil { + sp, err = c.owner.directiveArgsPacker(def, typ) + } else { + b := packer.NewBuilder() + sp, err = b.MakeStructPacker(def.Arguments, typ) + if err != nil { + return err + } + err = b.Finish() + } + if err != nil { + return err + } + if c.decodedArgs != nil { + if packed, ok := c.decodedArgs[typ]; ok { + rv.Elem().Set(packed.Elem()) + return nil + } + } + packed, err := sp.Pack(c.args) + if err != nil { + return err + } + if c.decodedArgs != nil { + c.decodedArgs[typ] = packed + } + rv.Elem().Set(packed.Elem()) + return nil +} + +func (c DirectiveContext) FieldArg(name string, dst any) error { + if c.fieldArgs == nil { + return fmt.Errorf("directive context is missing field arguments") + } + if dst == nil { + return fmt.Errorf("destination must be a non-nil pointer") + } + typ := reflect.TypeOf(dst) + if typ.Kind() != reflect.Pointer { + return fmt.Errorf("destination must be a pointer, got %s", typ) + } + rv := reflect.ValueOf(dst) + if rv.IsNil() { + return fmt.Errorf("destination must be a non-nil pointer") + } + + value, ok := c.fieldArgs[name] + if !ok { + return fmt.Errorf("field argument %q not found", name) + } + + packed, err := (&packer.ValuePacker{ValueType: typ.Elem()}).Pack(value) + if err != nil { + return err + } + rv.Elem().Set(packed) + return nil +} + +// DirectiveVisitor defines the interface for directive visitors. +type DirectiveVisitor interface { + // Name returns the name of the directive this visitor handles. + Name() string + // Visit is called when the directive is encountered during field definition traversal. + // Use [DirectiveContext.DecodeArgs] to parse the directive arguments. + Visit(ctx context.Context, d DirectiveContext) error +} + +// DirectiveVisitors registers one or more directive visitors with the schema. +// Visitor names must be unique within a schema. +func DirectiveVisitors(visitors ...DirectiveVisitor) SchemaOpt { + return func(s *Schema) { + for _, v := range visitors { + visitor, err := newDirectiveVisitor(v) + if err != nil { + s.optErr = err + return + } + s.directiveVisitors = append(s.directiveVisitors, visitor) + } + } +} + +func newDirectiveVisitor(visitor DirectiveVisitor) (DirectiveVisitor, error) { + if visitor == nil { + return nil, fmt.Errorf("directive visitor is nil") + } + + name := visitor.Name() + if name == "" { + return nil, fmt.Errorf("directive visitor must have a non-empty name") + } + + return visitor, nil +} + +func (s *Schema) validateDirectiveVisitors() error { + seen := make(map[string]struct{}, len(s.directiveVisitors)) + for _, v := range s.directiveVisitors { + name := v.Name() + def := s.schema.Directives[name] + if def == nil { + return fmt.Errorf("directive %q is not defined in the schema", name) + } + if _, ok := seen[name]; ok { + return fmt.Errorf("directive visitor %q is already registered", name) + } + seen[name] = struct{}{} + } + + return nil +} + +func directiveArgs(schema *ast.Schema, d *ast.Directive, vars map[string]any) (map[string]any, error) { + if d == nil { + return nil, fmt.Errorf("directive is nil") + } + def := schema.Directives[d.Name.Name] + if def == nil { + return nil, fmt.Errorf("directive %q is not defined in the schema", d.Name.Name) + } + args := make(map[string]any, len(def.Arguments)) + for _, arg := range def.Arguments { + if v, ok := d.Arguments.Get(arg.Name.Name); ok { + if isNilValue(v) { + args[arg.Name.Name] = nil + continue + } + args[arg.Name.Name] = v.Deserialize(vars) + continue + } + if arg.Default != nil { + args[arg.Name.Name] = arg.Default.Deserialize(nil) + continue + } + args[arg.Name.Name] = nil + } + return args, nil +} + +func isNilValue(v ast.Value) bool { + if v == nil { + return true + } + + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return rv.IsNil() + default: + return false + } +} + +func (s *Schema) directiveArgsPacker(def *ast.DirectiveDefinition, typ reflect.Type) (*packer.StructPacker, error) { + if def == nil { + return nil, fmt.Errorf("directive definition is nil") + } + key := directiveArgsCacheKey{def: def, typ: typ} + + s.directiveArgsMu.RLock() + sp := s.directiveArgsPackers[key] + s.directiveArgsMu.RUnlock() + if sp != nil { + return sp, nil + } + + b := packer.NewBuilder() + sp, err := b.MakeStructPacker(def.Arguments, typ) + if err != nil { + return nil, err + } + if err := b.Finish(); err != nil { + return nil, err + } + + s.directiveArgsMu.Lock() + defer s.directiveArgsMu.Unlock() + if s.directiveArgsPackers == nil { + s.directiveArgsPackers = make(map[directiveArgsCacheKey]*packer.StructPacker) + } + if cached := s.directiveArgsPackers[key]; cached != nil { + return cached, nil + } + s.directiveArgsPackers[key] = sp + return sp, nil +} + +func (s *Schema) buildDirectiveCaches() { + if s.directiveArgsPackers == nil { + s.directiveArgsPackers = make(map[directiveArgsCacheKey]*packer.StructPacker) + } + s.directiveVisitorsByName = make(map[string]DirectiveVisitor, len(s.directiveVisitors)) + for _, v := range s.directiveVisitors { + name := v.Name() + s.directiveVisitorsByName[name] = v + } +} + +func (s *Schema) runDirectiveVisitors(ctx context.Context, vars map[string]any, sels []selected.Selection) []*errors.QueryError { + if len(s.directiveVisitorsByName) == 0 { + return nil + } + + var errs []*errors.QueryError + + path := make([]any, 0, 8) + var walk func([]selected.Selection) + walk = func(selections []selected.Selection) { + for _, sel := range selections { + switch sel := sel.(type) { + case *selected.SchemaField: + path = append(path, sel.Alias) + for _, d := range sel.Directives { + hook, ok := s.directiveVisitorsByName[d.Name.Name] + if !ok { + continue + } + args, err := directiveArgs(s.schema, d, vars) + if err != nil { + errs = append(errs, &errors.QueryError{Message: err.Error(), Path: append([]any(nil), path...)}) + continue + } + dctx := DirectiveContext{ + owner: s, + schema: s.schema, + fieldArgs: sel.Args, + directive: d, + args: args, + } + err = hook.Visit(ctx, dctx) + if err != nil { + errs = append(errs, &errors.QueryError{Message: err.Error(), Path: append([]any(nil), path...)}) + } + } + walk(sel.Sels) + path = path[:len(path)-1] + case *selected.TypeAssertion: + walk(sel.Sels) + } + } + } + + walk(sels) + if len(errs) != 0 { + return errs + } + return nil +} diff --git a/directives_bench_test.go b/directives_bench_test.go new file mode 100644 index 000000000..d04381798 --- /dev/null +++ b/directives_bench_test.go @@ -0,0 +1,238 @@ +package graphql_test + +import ( + "context" + "fmt" + "strings" + "testing" + + graphql "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/ast" + "github.com/graph-gophers/graphql-go/example/starwars" +) + +const starwarsCostBenchmarkSchema = ` + directive @cost(weight: Int!, multiplier: String) on FIELD_DEFINITION + + schema { + query: Query + mutation: Mutation + } + + type Query { + hero(episode: Episode = NEWHOPE): Character @cost(weight: 1) + reviews(episode: Episode!): [Review]! @cost(weight: 2) + search(text: String!): [SearchResult]! @cost(weight: 3) + character(id: ID!): Character @cost(weight: 1) + droid(id: ID!): Droid @cost(weight: 1) + human(id: ID!): Human @cost(weight: 1) + starship(id: ID!): Starship @cost(weight: 1) + } + + type Mutation { + createReview(episode: Episode!, review: ReviewInput!): Review @cost(weight: 2) + } + + enum Episode { + NEWHOPE + EMPIRE + JEDI + } + + interface Character { + id: ID! @cost(weight: 1) + name: String! @cost(weight: 1) + friends: [Character] @cost(weight: 2) + friendsConnection(first: Int, after: ID): FriendsConnection! @cost(weight: 2, multiplier: "first") + appearsIn: [Episode!]! @cost(weight: 1) + } + + enum LengthUnit { + METER + FOOT + } + + type Human implements Character { + id: ID! @cost(weight: 1) + name: String! @cost(weight: 1) + height(unit: LengthUnit = METER): Float! @cost(weight: 1) + mass: Float @cost(weight: 1) + friends: [Character] @cost(weight: 2) + friendsConnection(first: Int, after: ID): FriendsConnection! @cost(weight: 2, multiplier: "first") + appearsIn: [Episode!]! @cost(weight: 1) + starships: [Starship] @cost(weight: 2) + } + + type Droid implements Character { + id: ID! @cost(weight: 1) + name: String! @cost(weight: 1) + friends: [Character] @cost(weight: 2) + friendsConnection(first: Int, after: ID): FriendsConnection! @cost(weight: 2, multiplier: "first") + appearsIn: [Episode!]! @cost(weight: 1) + primaryFunction: String @cost(weight: 1) + } + + type FriendsConnection { + totalCount: Int! @cost(weight: 1) + edges: [FriendsEdge] @cost(weight: 2) + friends: [Character] @cost(weight: 2) + pageInfo: PageInfo! @cost(weight: 1) + } + + type FriendsEdge { + cursor: ID! @cost(weight: 1) + node: Character @cost(weight: 1) + } + + type PageInfo { + startCursor: ID @cost(weight: 1) + endCursor: ID @cost(weight: 1) + hasNextPage: Boolean! @cost(weight: 1) + } + + type Review { + stars: Int! @cost(weight: 1) + commentary: String @cost(weight: 1) + } + + input ReviewInput { + stars: Int! + commentary: String + } + + type Starship { + id: ID! @cost(weight: 1) + name: String! @cost(weight: 1) + length(unit: LengthUnit = METER): Float! @cost(weight: 1) + } + + union SearchResult = Human | Droid | Starship +` + +type benchmarkCostDirective struct{} + +func (d *benchmarkCostDirective) Name() string { + return "cost" +} + +type benchmarkCostArgs struct { + Weight int32 + Multiplier graphql.NullString +} + +type benchmarkCostKey struct{} + +func (d *benchmarkCostDirective) Visit(ctx context.Context, dc graphql.DirectiveContext) error { + var args benchmarkCostArgs + if err := dc.DecodeArgs(&args); err != nil { + return err + } + if args.Weight < 0 { + return fmt.Errorf("invalid weight %d", args.Weight) + } + + multiplier := int32(1) + if args.Multiplier.Value != nil { + if err := dc.FieldArg(*args.Multiplier.Value, &multiplier); err != nil { + return err + } + if multiplier < 0 { + return fmt.Errorf("invalid multiplier %d", multiplier) + } + } + + total, _ := ctx.Value(benchmarkCostKey{}).(*uint) + if total != nil { + *total += uint(args.Weight) * uint(multiplier) + } + return nil +} + +func benchmarkThresholdHook(threshold uint) graphql.PreExecHookFunc { + return func(ctx context.Context, _ *ast.ExecutableDefinition, _ *ast.OperationDefinition, _ map[string]any) error { + total, _ := ctx.Value(benchmarkCostKey{}).(*uint) + if total != nil && *total > threshold { + return fmt.Errorf("query cost %d exceeds threshold %d", *total, threshold) + } + return nil + } +} + +const benchmarkCostThreshold = 80 + +var benchmarkCostResponseSink *graphql.Response + +func BenchmarkDirectiveVisitors_CostComplexityThreshold(b *testing.B) { + schema := graphql.MustParseSchema( + starwarsCostBenchmarkSchema, + &starwars.Resolver{}, + graphql.DirectiveVisitors(&benchmarkCostDirective{}), + graphql.PreExecHook(benchmarkThresholdHook(benchmarkCostThreshold)), + ) + + queries := []struct { + name string + query string + expectRejection bool + }{ + { + name: "SmallAllowed", + query: `{ hero { id name } }`, + expectRejection: false, + }, + { + name: "ConnectionAllowed", + query: `{ + hero { + friendsConnection(first: 10) { + totalCount + edges { node { id name } } + } + } + }`, + expectRejection: false, + }, + { + name: "SearchAllowed", + query: `{ + search(text: "R2") { + ... on Droid { id name primaryFunction } + ... on Human { id name starships { id name } } + } + }`, + expectRejection: false, + }, + { + name: "TooExpensiveRejected", + query: `{ + hero { + friendsConnection(first: 500) { + totalCount + edges { node { id name } } + } + } + }`, + expectRejection: true, + }, + } + + for _, tc := range queries { + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + var total uint + ctx := context.WithValue(context.Background(), benchmarkCostKey{}, &total) + resp := schema.Exec(ctx, tc.query, "", nil) + benchmarkCostResponseSink = resp + + rejected := len(resp.Errors) != 0 + if rejected != tc.expectRejection { + b.Fatalf("unexpected rejection=%t errors=%v", rejected, resp.Errors) + } + if tc.expectRejection && !strings.Contains(resp.Errors[0].Message, "exceeds threshold") { + b.Fatalf("unexpected rejection error: %+v", resp.Errors) + } + } + }) + } +} diff --git a/directives_test.go b/directives_test.go new file mode 100644 index 000000000..0d6503abe --- /dev/null +++ b/directives_test.go @@ -0,0 +1,320 @@ +package graphql_test + +import ( + "context" + "fmt" + "testing" + + "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/ast" + graphqlerrors "github.com/graph-gophers/graphql-go/errors" + "github.com/graph-gophers/graphql-go/gqltesting" +) + +type directiveVisitorResolver struct{} + +func (r *directiveVisitorResolver) Hello() string { + return "visitor" +} + +func (r *directiveVisitorResolver) A() string { + return "visitor-a" +} + +func (r *directiveVisitorResolver) B() string { + return "visitor-b" +} + +func (r *directiveVisitorResolver) Items(args struct{ PageSize int32 }) string { + return "visitor" +} + +// validateDirective is an implementation of the DirectiveVisitor interface +type validateDirective struct { + handler func(ctx context.Context, args struct{ Pattern string }, d graphql.DirectiveContext) error +} + +func (v *validateDirective) Name() string { + return "validate" +} + +func (v *validateDirective) Visit(ctx context.Context, d graphql.DirectiveContext) error { + var args struct{ Pattern string } + if err := d.DecodeArgs(&args); err != nil { + return err + } + return v.handler(ctx, args, d) +} + +// TestDirectiveVisitors tests the new generic directive visitor API +func TestDirectiveVisitors(t *testing.T) { + t.Parallel() + + gqltesting.RunTests(t, []*gqltesting.Test{ + { + Schema: graphql.MustParseSchema(` + directive @validate(pattern: String!) on FIELD_DEFINITION + + type Query { + hello: String! @validate(pattern: ".*") + } + `, &directiveVisitorResolver{}, graphql.DirectiveVisitors(&validateDirective{ + handler: func(ctx context.Context, args struct{ Pattern string }, d graphql.DirectiveContext) error { + return nil + }, + })), + Query: `{ hello }`, + ExpectedResult: `{"hello":"visitor"}`, + }, + { + Schema: graphql.MustParseSchema(` + directive @validate(pattern: String!) on FIELD_DEFINITION + + type Query { + hello: String! @validate(pattern: "xyz") + } + `, &directiveVisitorResolver{}, + graphql.DirectiveVisitors(&validateDirective{ + handler: func(ctx context.Context, args struct{ Pattern string }, d graphql.DirectiveContext) error { + return fmt.Errorf("validation failed") + }, + })), + Query: `{ hello }`, + ExpectedErrors: []*graphqlerrors.QueryError{ + { + Message: "validation failed", Path: []any{"hello"}, + }, + }, + }, + }) +} + +type costDirArgs struct { + Weight int32 + Multiplier graphql.NullString +} + +// costDirectiveWithContext is an implementation of the DirectiveVisitor interface +type costDirectiveWithContext struct { + handler func(ctx context.Context, args costDirArgs, d graphql.DirectiveContext) error +} + +func (c *costDirectiveWithContext) Name() string { + return "cost" +} + +func (c *costDirectiveWithContext) Visit(ctx context.Context, d graphql.DirectiveContext) error { + var args costDirArgs + if err := d.DecodeArgs(&args); err != nil { + return err + } + return c.handler(ctx, args, d) +} + +// TestDirectiveVisitorsWithContext tests context-aware visitors with nullable args. +func TestDirectiveVisitorsWithContext(t *testing.T) { + t.Parallel() + + gqltesting.RunTests(t, []*gqltesting.Test{ + { + Schema: graphql.MustParseSchema(` + directive @cost(weight: Int!, multiplier: String) on FIELD_DEFINITION + + type Query { + items(pageSize: Int!): String! @cost(weight: 2, multiplier: "pageSize") + } + `, &directiveVisitorResolver{}, + graphql.DirectiveVisitors(&costDirectiveWithContext{ + handler: func(ctx context.Context, args costDirArgs, d graphql.DirectiveContext) error { + multiplier := int32(1) + if args.Multiplier.Value != nil { + if err := d.FieldArg(*args.Multiplier.Value, &multiplier); err != nil { + return err + } + } + if args.Weight*multiplier > 5 { + return fmt.Errorf("total cost %d exceeds budget", args.Weight*multiplier) + } + return nil + }, + })), + Query: `{ items(pageSize: 3) }`, + ExpectedErrors: []*graphqlerrors.QueryError{{Message: "total cost 6 exceeds budget", Path: []any{"items"}}}, + }, + }) +} + +type preExecHookResolver struct { + calls int +} + +func (r *preExecHookResolver) A() string { + r.calls++ + return "a" +} + +func (r *preExecHookResolver) B() string { + r.calls++ + return "b" +} + +type complexityTotalKey struct{} + +// costDirectiveForPreExec is an implementation of the DirectiveVisitor interface +type costDirectiveForPreExec struct { + handler func(ctx context.Context, args struct{ Weight int32 }, d graphql.DirectiveContext) error +} + +func (c *costDirectiveForPreExec) Name() string { + return "cost" +} + +func (c *costDirectiveForPreExec) Visit(ctx context.Context, d graphql.DirectiveContext) error { + var args struct{ Weight int32 } + if err := d.DecodeArgs(&args); err != nil { + return err + } + return c.handler(ctx, args, d) +} + +func TestPreExecHookWithDirectiveVisitors(t *testing.T) { + t.Parallel() + + resolver := &preExecHookResolver{} + schema := graphql.MustParseSchema(` + directive @cost(weight: Int!) on FIELD_DEFINITION + + type Query { + a: String! @cost(weight: 3) + b: String! @cost(weight: 4) + } + `, resolver, + graphql.DirectiveVisitors(&costDirectiveForPreExec{ + handler: func(ctx context.Context, args struct{ Weight int32 }, d graphql.DirectiveContext) error { + total, _ := ctx.Value(complexityTotalKey{}).(*int32) + if total != nil { + *total += args.Weight + } + return nil + }, + }), + graphql.PreExecHook(func(ctx context.Context, _ *ast.ExecutableDefinition, _ *ast.OperationDefinition, _ map[string]any) error { + total, _ := ctx.Value(complexityTotalKey{}).(*int32) + if total != nil && *total > 5 { + return fmt.Errorf("total cost %d exceeds budget", *total) + } + return nil + }), + ) + + var total int32 + ctx := context.WithValue(context.Background(), complexityTotalKey{}, &total) + resp := schema.Exec(ctx, `{ a b }`, "", nil) + + if len(resp.Errors) != 1 || resp.Errors[0].Message != "total cost 7 exceeds budget" { + t.Fatalf("unexpected errors: %+v", resp.Errors) + } + if resolver.calls != 0 { + t.Fatalf("pre-exec hook should stop execution, got %d resolver calls", resolver.calls) + } +} + +type directiveVisitorCallListKey struct{} + +type recordingDirective struct { + name string + label string +} + +func (v *recordingDirective) Name() string { + return v.name +} + +func (v *recordingDirective) Visit(ctx context.Context, d graphql.DirectiveContext) error { + var args struct{ Pattern string } + if err := d.DecodeArgs(&args); err != nil { + return err + } + + calls, _ := ctx.Value(directiveVisitorCallListKey{}).(*[]string) + if calls != nil { + *calls = append(*calls, v.label+":"+args.Pattern) + } + return nil +} + +func TestDirectiveVisitors_DuplicateNamesRejected(t *testing.T) { + t.Parallel() + + _, err := graphql.ParseSchema(` + directive @validate(pattern: String!) on FIELD_DEFINITION + + type Query { + hello: String! @validate(pattern: "ok") + } + `, &directiveVisitorResolver{}, + graphql.DirectiveVisitors( + &recordingDirective{name: "validate", label: "first"}, + &recordingDirective{name: "validate", label: "second"}, + ), + ) + if err == nil { + t.Fatal("expected duplicate directive visitor registration to fail") + } + if got, want := err.Error(), `directive visitor "validate" is already registered`; got != want { + t.Fatalf("unexpected error: got %q want %q", got, want) + } +} + +func TestDirectiveVisitors_CloneDuplicateNamesRejected(t *testing.T) { + t.Parallel() + + base := graphql.MustParseSchema(` + directive @validate(pattern: String!) on FIELD_DEFINITION + + type Query { + hello: String! @validate(pattern: "clone") + } + `, &directiveVisitorResolver{}, + graphql.DirectiveVisitors(&recordingDirective{name: "validate", label: "base"}), + ) + + _, err := base.Clone( + &directiveVisitorResolver{}, + graphql.DirectiveVisitors(&recordingDirective{name: "validate", label: "clone"}), + ) + if err == nil { + t.Fatal("expected clone with duplicate directive visitor to fail") + } + if got, want := err.Error(), `directive visitor "validate" is already registered`; got != want { + t.Fatalf("unexpected error: got %q want %q", got, want) + } +} + +func TestDirectiveVisitors_DistinctNamesDispatch(t *testing.T) { + t.Parallel() + + schema := graphql.MustParseSchema(` + directive @validateA(pattern: String!) on FIELD_DEFINITION + directive @validateB(pattern: String!) on FIELD_DEFINITION + + type Query { + hello: String! @validateA(pattern: "first") @validateB(pattern: "second") + } + `, &directiveVisitorResolver{}, + graphql.DirectiveVisitors( + &recordingDirective{name: "validateA", label: "a"}, + &recordingDirective{name: "validateB", label: "b"}, + ), + ) + + var calls []string + ctx := context.WithValue(context.Background(), directiveVisitorCallListKey{}, &calls) + resp := schema.Exec(ctx, `{ hello }`, "", nil) + if len(resp.Errors) != 0 { + t.Fatalf("unexpected errors: %+v", resp.Errors) + } + if len(calls) != 2 || calls[0] != "a:first" || calls[1] != "b:second" { + t.Fatalf("unexpected visitor calls: %+v", calls) + } +} diff --git a/example_directives_test.go b/example_directives_test.go new file mode 100644 index 000000000..13209064b --- /dev/null +++ b/example_directives_test.go @@ -0,0 +1,203 @@ +package graphql_test + +import ( + "context" + "fmt" + "sync/atomic" + + "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/ast" +) + +type authRoleKey struct{} + +type authResolver struct { + calls int +} + +func (r *authResolver) Secret() string { + r.calls++ + return "classified" +} + +type authDirective struct{} + +func (a *authDirective) Name() string { + return "auth" +} + +type authDirectiveArgs struct { + Role string +} + +func (a *authDirective) Visit(ctx context.Context, d graphql.DirectiveContext) error { + var args authDirectiveArgs + if err := d.DecodeArgs(&args); err != nil { + return err + } + + role, _ := ctx.Value(authRoleKey{}).(string) + requiredRole := args.Role + if role != requiredRole { + return fmt.Errorf("forbidden") + } + return nil +} + +func ExampleDirectiveVisitors_auth() { + resolver := &authResolver{} + sdl := ` + enum Role { + ADMIN + MEMBER + } + + directive @auth(role: Role!) on FIELD_DEFINITION + + type Query { + secret: String! @auth(role: ADMIN) + } + ` + + dirs := []graphql.SchemaOpt{ + graphql.DirectiveVisitors(&authDirective{}), + } + + schema := graphql.MustParseSchema(sdl, resolver, dirs...) + + adminCtx := context.WithValue(context.Background(), authRoleKey{}, "ADMIN") + admin := schema.Exec(adminCtx, `{ secret }`, "", nil) + fmt.Println("admin:", string(admin.Data)) + fmt.Println("admin calls:", resolver.calls) + + resolver.calls = 0 + memberCtx := context.WithValue(context.Background(), authRoleKey{}, "MEMBER") + member := schema.Exec(memberCtx, `{ secret }`, "", nil) + fmt.Printf("member errors: %+v\n", member.Errors) + fmt.Println("member calls:", resolver.calls) + + // Output: + // admin: {"secret":"classified"} + // admin calls: 1 + // member errors: [graphql: forbidden] + // member calls: 0 +} + +type costContextKey struct{} + +type costResolver struct { + calls atomic.Int32 +} + +func (r *costResolver) Greet() string { + r.calls.Add(1) + return "hello" +} + +func (r *costResolver) Items(ctx context.Context, args struct{ PageSize int32 }) ([]string, error) { + r.calls.Add(1) + if args.PageSize < 1 || args.PageSize > 100 { + return nil, fmt.Errorf("pageSize must be between 1 and 100") + } + var res []string + for range args.PageSize { + res = append(res, "gopher") + } + return res, nil +} + +type costDirective struct{} + +func (c *costDirective) Name() string { + return "cost" +} + +func (c *costDirective) CheckThreshold(threshold uint) graphql.PreExecHookFunc { + return func(ctx context.Context, _ *ast.ExecutableDefinition, _ *ast.OperationDefinition, _ map[string]any) error { + if c.TotalCost(ctx) > threshold { + return fmt.Errorf("query cost %d exceeds threshold %d", c.TotalCost(ctx), threshold) + } + return nil + } +} + +func (c *costDirective) Add(ctx context.Context, cost uint) { + total, _ := ctx.Value(costContextKey{}).(*uint) + if total != nil { + *total += cost + } +} + +func (c *costDirective) TotalCost(ctx context.Context) uint { + total, _ := ctx.Value(costContextKey{}).(*uint) + if total == nil { + return 0 + } + return *total +} + +type costDirectiveArgs struct { + Weight int32 + Multiplier graphql.NullString +} + +func (c *costDirective) Visit(ctx context.Context, d graphql.DirectiveContext) error { + var args costDirectiveArgs + if err := d.DecodeArgs(&args); err != nil { + return err + } + + var multiplier int32 = 1 + if args.Multiplier.Value != nil { // multiplier is optional + arg := *args.Multiplier.Value + if err := d.FieldArg(arg, &multiplier); err != nil { + return fmt.Errorf("decode multiplier %q: %s", arg, err) + } + } + + c.Add(ctx, uint(args.Weight)*uint(multiplier)) + return nil +} + +func ExampleDirectiveVisitors_costMultiplier() { + resolver := &costResolver{} + complexity := &costDirective{} + sdl := ` + directive @cost(weight: Int!, multiplier: String) on FIELD_DEFINITION + + type Query { + greet: String! @cost(weight: 1) + items(pageSize: Int!): [String!]! @cost(weight: 1, multiplier: "pageSize") + } + ` + + opts := []graphql.SchemaOpt{ + graphql.DirectiveVisitors(complexity), + graphql.PreExecHook(complexity.CheckThreshold(10)), + } + + schema := graphql.MustParseSchema(sdl, resolver, opts...) + + var allowedTotal uint + allowedCtx := context.WithValue(context.Background(), costContextKey{}, &allowedTotal) + allowed := schema.Exec(allowedCtx, `{ greet items(pageSize: 5) }`, "", nil) + fmt.Println("allowed:", string(allowed.Data)) + fmt.Printf("allowed errors: %+v\n", allowed.Errors) + fmt.Println("allowed calls:", resolver.calls.Load()) + + resolver.calls.Store(0) + var blockedTotal uint + blockedCtx := context.WithValue(context.Background(), costContextKey{}, &blockedTotal) + blocked := schema.Exec(blockedCtx, `{ greet items(pageSize: 50) }`, "", nil) + fmt.Printf("blocked:%s\n", blocked.Data) + fmt.Printf("blocked errors: %+v\n", blocked.Errors) + fmt.Println("blocked calls:", resolver.calls.Load()) + + // Output: + // allowed: {"greet":"hello","items":["gopher","gopher","gopher","gopher","gopher"]} + // allowed errors: [] + // allowed calls: 2 + // blocked: + // blocked errors: [graphql: query cost 51 exceeds threshold 10] + // blocked calls: 0 +} diff --git a/graphql.go b/graphql.go index 3e9ce5e4d..cf6f39a74 100644 --- a/graphql.go +++ b/graphql.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "sync" "time" @@ -11,6 +12,7 @@ import ( "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/packer" "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" @@ -39,6 +41,9 @@ func ParseSchema(schemaString string, resolver any, opts ...SchemaOpt) (*Schema, for _, opt := range opts { opt(s) } + if s.optErr != nil { + return nil, s.optErr + } if !s.disableMemoryPooling && s.maxPooledBufferCapacity <= 0 { s.maxPooledBufferCapacity = defaultMaxPooledBufferCapacity } @@ -54,9 +59,13 @@ func ParseSchema(schemaString string, resolver any, opts ...SchemaOpt) (*Schema, if err := schema.Parse(s.schema, schemaString, s.useStringDescriptions); err != nil { return nil, err } + if err := s.validateDirectiveVisitors(); err != nil { + return nil, err + } if err := s.validateSchema(); err != nil { return nil, err } + s.buildDirectiveCaches() r, err := resolvable.ApplyResolver(s.schema, resolver, s.useFieldResolvers) if err != nil { @@ -92,7 +101,6 @@ func MustParseSchema(schemaString string, resolver any, opts ...SchemaOpt) *Sche // privateSchema := graphql.MustParseSchema(schema, resolver, graphql.MaxDepth(10)) // publicSchema, _ := privateSchema.Clone(resolver, graphql.MaxDepth(3)) func (s *Schema) Clone(resolver any, opts ...SchemaOpt) (*Schema, error) { - // Create new schema with shared AST and copied configuration clone := &Schema{ schema: s.schema, maxParallelism: s.maxParallelism, @@ -111,17 +119,30 @@ func (s *Schema) Clone(resolver any, opts ...SchemaOpt) (*Schema, error) { disableMemoryPooling: s.disableMemoryPooling, overlapPairLimit: s.overlapPairLimit, validateDeprecated: s.validateDeprecated, + preExecHook: s.preExecHook, + directiveVisitors: append([]DirectiveVisitor(nil), s.directiveVisitors...), + directiveArgsPackers: make(map[directiveArgsCacheKey]*packer.StructPacker), } + s.directiveArgsMu.RLock() + maps.Copy(clone.directiveArgsPackers, s.directiveArgsPackers) + s.directiveArgsMu.RUnlock() for _, opt := range opts { opt(clone) } + if clone.optErr != nil { + return nil, clone.optErr + } + if err := clone.validateDirectiveVisitors(); err != nil { + return nil, err + } res, err := resolvable.ApplyResolver(clone.schema, resolver, clone.useFieldResolvers) if err != nil { return nil, err } clone.res = res + clone.buildDirectiveCaches() return clone, nil } @@ -196,8 +217,16 @@ type Schema struct { maxPooledBufferCapacity int overlapPairLimit int validateDeprecated bool + directiveVisitors []DirectiveVisitor + directiveVisitorsByName map[string]DirectiveVisitor + preExecHook PreExecHookFunc + directiveArgsMu sync.RWMutex + directiveArgsPackers map[directiveArgsCacheKey]*packer.StructPacker + optErr error } +type PreExecHookFunc func(ctx context.Context, document *ast.ExecutableDefinition, operation *ast.OperationDefinition, variables map[string]any) error + // AST returns the abstract syntax tree of the GraphQL schema definition. // It in turn can be used by other tools such as validators or generators. func (s *Schema) AST() *ast.Schema { @@ -362,6 +391,14 @@ func SubscribeResolverTimeout(timeout time.Duration) SchemaOpt { } } +// PreExecHook configures an optional callback that runs after validation and +// directive pre-execution checks but before resolver execution starts. +func PreExecHook(hook PreExecHookFunc) SchemaOpt { + return func(s *Schema) { + s.preExecHook = hook + } +} + // 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 the [spec]. @@ -438,8 +475,7 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str return &Response{Errors: []*errors.QueryError{{Message: "no mutations are offered by the schema"}}} } } - - // Fill in variables with the defaults from the operation + // Fill in variables with the defaults from the operation. if variables == nil { variables = make(map[string]any, len(op.Vars)) } @@ -449,12 +485,13 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str } } + allowIntrospection := s.allowIntrospection == nil || s.allowIntrospection(ctx) // allow introspection by default, i.e. when allowIntrospection is nil r := &exec.Request{ Request: selected.Request{ Doc: doc, Vars: variables, Schema: s.schema, - AllowIntrospection: s.allowIntrospection == nil || s.allowIntrospection(ctx), // allow introspection by default, i.e. when allowIntrospection is nil + AllowIntrospection: allowIntrospection, }, Limiter: make(chan struct{}, s.maxParallelism), Tracer: s.tracer, @@ -464,6 +501,20 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str DisableMemoryPooling: s.disableMemoryPooling, MaxPooledBufferCapacity: s.maxPooledBufferCapacity, } + sels := selected.ApplyOperation(&r.Request, res, op) + if errs = s.runDirectiveVisitors(ctx, variables, sels); len(errs) != 0 { + return &Response{Errors: errs} + } + + if s.preExecHook != nil { + if err := s.preExecHook(ctx, doc, op, variables); err != nil { + if qErr, ok := err.(*errors.QueryError); ok { + return &Response{Errors: []*errors.QueryError{qErr}} + } + return &Response{Errors: []*errors.QueryError{{Message: err.Error()}}} + } + } + r.Selections = sels varTypes := make(map[string]*introspection.Type) for _, v := range op.Vars { t, err := common.ResolveType(v.Type, s.schema.Resolve) diff --git a/internal/exec/directive/directive.go b/internal/exec/directive/directive.go new file mode 100644 index 000000000..2dfd227ee --- /dev/null +++ b/internal/exec/directive/directive.go @@ -0,0 +1,42 @@ +package directive + +import ( + "fmt" + "reflect" + + "github.com/graph-gophers/graphql-go/ast" + "github.com/graph-gophers/graphql-go/internal/exec/packer" +) + +func ShouldSkipSelection(vars map[string]any, directives ast.DirectiveList) (bool, error) { + if d := directives.Get("skip"); d != nil { + ok, err := decodeBoolArg(vars, d) + if err != nil { + return false, err + } + if ok { + return true, nil + } + } + + if d := directives.Get("include"); d != nil { + ok, err := decodeBoolArg(vars, d) + if err != nil { + return false, err + } + if !ok { + return true, nil + } + } + + return false, nil +} + +func decodeBoolArg(vars map[string]any, d *ast.Directive) (bool, error) { + p := packer.ValuePacker{ValueType: reflect.TypeFor[bool]()} + v, err := p.Pack(d.Arguments.MustGet("if").Deserialize(vars)) + if err != nil { + return false, fmt.Errorf("%s", err) + } + return v.Bool(), nil +} diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 702da93c1..f67a96e3e 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -29,6 +29,7 @@ var nullLiteral = []byte("null") //nolint:gochecknoglobals type Request struct { selected.Request + Selections []selected.Selection Limiter chan struct{} Tracer tracer.Tracer Logger log.Logger @@ -54,7 +55,10 @@ func (r *Request) Execute(ctx context.Context, s *resolvable.Schema, op *ast.Ope var out bytes.Buffer func() { defer r.handlePanic(ctx) - sels := selected.ApplyOperation(&r.Request, s, op) + sels := r.Selections + if sels == nil { + sels = selected.ApplyOperation(&r.Request, s, op) + } var resolver reflect.Value switch op.Type { case query.Query: @@ -95,10 +99,6 @@ type fieldToExec struct { out *bytes.Buffer } -func (f *fieldToExec) resolve(ctx context.Context) (output any, err error) { - return f.field.Resolve(ctx, f.resolver) -} - func resolvedToNull(b *bytes.Buffer) bool { return bytes.Equal(b.Bytes(), nullLiteral) } @@ -132,6 +132,25 @@ func (r *Request) releaseFieldBuffers(fields []*fieldToExec) { } } +func (r *Request) resolveField(ctx context.Context, f *fieldToExec, path *pathSegment) (reflect.Value, *errors.QueryError) { + if f.field.FixedResult.IsValid() { + return f.field.FixedResult, nil + } + + res, resolverErr := f.field.Resolve(ctx, f.resolver) + if resolverErr == nil { + return reflect.ValueOf(res), nil + } + + err := errors.Errorf("%s", resolverErr) + err.Path = path.toSlice() + err.ResolverError = resolverErr + if ex, ok := resolverErr.(extensionser); ok { + err.Extensions = ex.Extensions() + } + return reflect.Value{}, err +} + 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) @@ -244,8 +263,8 @@ func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f r.Limiter <- struct{}{} } - var result reflect.Value var err *errors.QueryError + var result reflect.Value traceCtx, finish := r.Tracer.TraceField(ctx, f.field.TraceLabel, f.field.TypeName, f.field.Name, !f.field.Async, f.field.Args) defer finish(err) @@ -259,11 +278,6 @@ func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f } }() - if f.field.FixedResult.IsValid() { - result = f.field.FixedResult - return nil - } - if err := traceCtx.Err(); err != nil { return errors.Errorf("%s", err) // don't execute any more resolvers if context got cancelled } @@ -271,18 +285,12 @@ func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f if len(f.sels) > 0 && !r.DisableFieldSelections { ctx = selections.With(traceCtx, f.sels) } - res, resolverErr := f.resolve(ctx) - if resolverErr != nil { - err := errors.Errorf("%s", resolverErr) - err.Path = path.toSlice() - err.ResolverError = resolverErr - if ex, ok := resolverErr.(extensionser); ok { - err.Extensions = ex.Extensions() - } - return err + res, resolveErr := r.resolveField(ctx, f, path) + if resolveErr != nil { + return resolveErr } - result = reflect.ValueOf(res) + result = res return nil }() diff --git a/internal/exec/selected/selected.go b/internal/exec/selected/selected.go index 70b7d1e30..3c2a9df77 100644 --- a/internal/exec/selected/selected.go +++ b/internal/exec/selected/selected.go @@ -8,6 +8,7 @@ import ( "github.com/graph-gophers/graphql-go/ast" "github.com/graph-gophers/graphql-go/errors" + execdirective "github.com/graph-gophers/graphql-go/internal/exec/directive" "github.com/graph-gophers/graphql-go/internal/exec/packer" "github.com/graph-gophers/graphql-go/internal/exec/resolvable" "github.com/graph-gophers/graphql-go/internal/query" @@ -79,7 +80,11 @@ func applySelectionSet(r *Request, s *resolvable.Schema, e *resolvable.Object, s switch sel := sel.(type) { case *ast.Field: field := sel - if skipByDirective(r, field.Directives) { + skip, err := execdirective.ShouldSkipSelection(r.Vars, field.Directives) + if err != nil { + r.AddError(errors.Errorf("%s", err)) + } + if skip { continue } @@ -160,14 +165,22 @@ func applySelectionSet(r *Request, s *resolvable.Schema, e *resolvable.Object, s case *ast.InlineFragment: frag := sel - if skipByDirective(r, frag.Directives) { + skip, err := execdirective.ShouldSkipSelection(r.Vars, frag.Directives) + if err != nil { + r.AddError(errors.Errorf("%s", err)) + } + if skip { continue } flattenedSels = append(flattenedSels, applyFragment(r, s, e, &frag.Fragment)...) case *ast.FragmentSpread: spread := sel - if skipByDirective(r, spread.Directives) { + skip, err := execdirective.ShouldSkipSelection(r.Vars, spread.Directives) + if err != nil { + r.AddError(errors.Errorf("%s", err)) + } + if skip { continue } flattenedSels = append(flattenedSels, applyFragment(r, s, e, &r.Doc.Fragments.Get(spread.Name.Name).Fragment)...) @@ -263,32 +276,6 @@ func applyField(r *Request, s *resolvable.Schema, e resolvable.Resolvable, sels } } -func skipByDirective(r *Request, directives ast.DirectiveList) bool { - if d := directives.Get("skip"); d != nil { - p := packer.ValuePacker{ValueType: reflect.TypeFor[bool]()} - v, err := p.Pack(d.Arguments.MustGet("if").Deserialize(r.Vars)) - if err != nil { - r.AddError(errors.Errorf("%s", err)) - } - if err == nil && v.Bool() { - return true - } - } - - if d := directives.Get("include"); d != nil { - p := packer.ValuePacker{ValueType: reflect.TypeFor[bool]()} - v, err := p.Pack(d.Arguments.MustGet("if").Deserialize(r.Vars)) - if err != nil { - r.AddError(errors.Errorf("%s", err)) - } - if err == nil && !v.Bool() { - return true - } - } - - return false -} - func HasAsyncSel(sels []Selection) bool { for _, sel := range sels { switch sel := sel.(type) { diff --git a/internal/exec/subscribe.go b/internal/exec/subscribe.go index 7056e0cab..b66ef6a2e 100644 --- a/internal/exec/subscribe.go +++ b/internal/exec/subscribe.go @@ -26,32 +26,15 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *ast.O func() { defer r.handlePanic(ctx) - sels := selected.ApplyOperation(&r.Request, s, op) + sels := r.Selections + if sels == nil { + sels = selected.ApplyOperation(&r.Request, s, op) + } var fields []*fieldToExec collectFieldsToResolve(sels, s, s.SubscriptionResolver, &fields, make(map[string]*fieldToExec)) 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() { - switch resolverErr := callOut[1].Interface().(type) { - case *errors.QueryError: - err = resolverErr - case error: - err = errors.Errorf("%s", resolverErr) - err.ResolverError = resolverErr - default: - panic(fmt.Errorf("can only deal with *QueryError and error types, got %T", resolverErr)) - } - } + result, err = r.resolveSubscriptionField(ctx, f) }() // Handles the case where the locally executed func above panicked @@ -169,6 +152,23 @@ func (r *Request) Subscribe(ctx context.Context, s *resolvable.Schema, op *ast.O return c } +func (r *Request) resolveSubscriptionField(ctx context.Context, f *fieldToExec) (reflect.Value, *errors.QueryError) { + res, resolverErr := f.field.Resolve(ctx, f.resolver) + if resolverErr == nil { + return reflect.ValueOf(res), nil + } + switch resolverErr := resolverErr.(type) { + case *errors.QueryError: + return reflect.Value{}, resolverErr + case error: + err := errors.Errorf("%s", resolverErr) + err.ResolverError = resolverErr + return reflect.Value{}, err + default: + panic(fmt.Errorf("can only deal with *QueryError and error types, got %T", resolverErr)) + } +} + func sendAndReturnClosed(resp *Response) chan *Response { c := make(chan *Response, 1) c <- resp diff --git a/subscriptions.go b/subscriptions.go index c6dedaec0..91e8e597e 100644 --- a/subscriptions.go +++ b/subscriptions.go @@ -51,6 +51,15 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{qerrors.Errorf("%s", err)}}) } + if variables == nil { + variables = make(map[string]any, len(op.Vars)) + } + for _, v := range op.Vars { + if _, ok := variables[v.Name.Name]; !ok && v.Default != nil { + variables[v.Name.Name] = v.Default.Deserialize(nil) + } + } + r := &exec.Request{ Request: selected.Request{ Doc: doc, @@ -65,6 +74,7 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam DisableMemoryPooling: s.disableMemoryPooling, MaxPooledBufferCapacity: s.maxPooledBufferCapacity, } + sels := selected.ApplyOperation(&r.Request, res, op) varTypes := make(map[string]*introspection.Type) for _, v := range op.Vars { t, err := common.ResolveType(v.Type, s.schema.Resolve) @@ -73,6 +83,18 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam } varTypes[v.Name.Name] = introspection.WrapType(t) } + if errs := s.runDirectiveVisitors(ctx, variables, sels); len(errs) != 0 { + return sendAndReturnClosed(&Response{Errors: errs}) + } + if s.preExecHook != nil { + if err := s.preExecHook(ctx, doc, op, variables); err != nil { + if qErr, ok := err.(*qerrors.QueryError); ok { + return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{qErr}}) + } + return sendAndReturnClosed(&Response{Errors: []*qerrors.QueryError{{Message: err.Error()}}}) + } + } + r.Selections = sels if op.Type == query.Query || op.Type == query.Mutation { data, errs := r.Execute(ctx, res, op)