Skip to content

Commit ca50a02

Browse files
committed
feat: add support for directives
1 parent 622118b commit ca50a02

11 files changed

Lines changed: 1252 additions & 76 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ schema := graphql.MustParseSchema(sdl, &RootResolver{}, nil)
163163
- `DisableFieldSelections()` disables capturing child field selections used by helper APIs (see below).
164164
- `DisableMemoryPooling()` disables internal execution-path memory pooling. Pooling is enabled by default; this option is intended for diagnostics and benchmark comparisons.
165165
- `OverlapValidationLimit(n int)` sets a hard cap on examined overlap pairs during validation; exceeding it emits `OverlapValidationLimitExceeded` error.
166+
- `DirectiveVisitors(...)` registers directive visitors to inspect and validate directives during pre-execution analysis. Visitors are generic: they can implement authorization, cost analysis, analytics, or any custom logic. Users provide `DirectiveVisitor` registrations with a `Name` and a `Visit` callback that receives typed directive args as its second argument (after `context.Context`) and returns `error`. The callback may optionally take `graphql.DirectiveContext` as a third argument to read field arguments by name with `FieldArg`. Visitor names must be unique per schema.
167+
- `PreExecHook(...)` registers a callback that runs after validation and directive pre-execution checks, but before resolver execution. Returning an error aborts execution.
166168

167169
### Field Selection Inspection Helpers
168170

@@ -283,4 +285,9 @@ type Tracer interface {
283285
}
284286
```
285287

288+
### Directive Visitors
289+
290+
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.
291+
Each directive name can have at most one registered visitor per schema.
292+
286293
### [Examples](https://github.com/graph-gophers/graphql-go/wiki/Examples)

directives.go

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
package graphql
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"reflect"
7+
8+
"github.com/graph-gophers/graphql-go/ast"
9+
"github.com/graph-gophers/graphql-go/errors"
10+
"github.com/graph-gophers/graphql-go/internal/exec/packer"
11+
"github.com/graph-gophers/graphql-go/internal/exec/selected"
12+
)
13+
14+
type directiveArgsCacheKey struct {
15+
def *ast.DirectiveDefinition
16+
typ reflect.Type
17+
}
18+
19+
type DirectiveContext struct {
20+
owner *Schema
21+
schema *ast.Schema
22+
directive *ast.Directive
23+
args map[string]any
24+
fieldArgs map[string]any
25+
decodedArgs map[reflect.Type]reflect.Value
26+
}
27+
28+
func (c DirectiveContext) DecodeArgs(dst any) error {
29+
if c.schema == nil || c.directive == nil {
30+
return fmt.Errorf("directive context is missing schema metadata")
31+
}
32+
def := c.schema.Directives[c.directive.Name.Name]
33+
if def == nil {
34+
return fmt.Errorf("directive %q is not defined in the schema", c.directive.Name.Name)
35+
}
36+
if dst == nil {
37+
return fmt.Errorf("destination must be a non-nil pointer")
38+
}
39+
typ := reflect.TypeOf(dst)
40+
if typ.Kind() != reflect.Pointer {
41+
return fmt.Errorf("destination must be a pointer, got %s", typ)
42+
}
43+
rv := reflect.ValueOf(dst)
44+
if rv.IsNil() {
45+
return fmt.Errorf("destination must be a non-nil pointer")
46+
}
47+
var (
48+
sp *packer.StructPacker
49+
err error
50+
)
51+
if c.owner != nil {
52+
sp, err = c.owner.directiveArgsPacker(def, typ)
53+
} else {
54+
b := packer.NewBuilder()
55+
sp, err = b.MakeStructPacker(def.Arguments, typ)
56+
if err != nil {
57+
return err
58+
}
59+
err = b.Finish()
60+
}
61+
if err != nil {
62+
return err
63+
}
64+
if c.decodedArgs != nil {
65+
if packed, ok := c.decodedArgs[typ]; ok {
66+
rv.Elem().Set(packed.Elem())
67+
return nil
68+
}
69+
}
70+
packed, err := sp.Pack(c.args)
71+
if err != nil {
72+
return err
73+
}
74+
if c.decodedArgs != nil {
75+
c.decodedArgs[typ] = packed
76+
}
77+
rv.Elem().Set(packed.Elem())
78+
return nil
79+
}
80+
81+
func (c DirectiveContext) FieldArg(name string, dst any) error {
82+
if c.fieldArgs == nil {
83+
return fmt.Errorf("directive context is missing field arguments")
84+
}
85+
if dst == nil {
86+
return fmt.Errorf("destination must be a non-nil pointer")
87+
}
88+
typ := reflect.TypeOf(dst)
89+
if typ.Kind() != reflect.Pointer {
90+
return fmt.Errorf("destination must be a pointer, got %s", typ)
91+
}
92+
rv := reflect.ValueOf(dst)
93+
if rv.IsNil() {
94+
return fmt.Errorf("destination must be a non-nil pointer")
95+
}
96+
97+
value, ok := c.fieldArgs[name]
98+
if !ok {
99+
return fmt.Errorf("field argument %q not found", name)
100+
}
101+
102+
packed, err := (&packer.ValuePacker{ValueType: typ.Elem()}).Pack(value)
103+
if err != nil {
104+
return err
105+
}
106+
rv.Elem().Set(packed)
107+
return nil
108+
}
109+
110+
// DirectiveVisitor defines the interface for directive visitors.
111+
type DirectiveVisitor interface {
112+
// Name returns the name of the directive this visitor handles.
113+
Name() string
114+
// Visit is called when the directive is encountered during field definition traversal.
115+
// Use [DirectiveContext.DecodeArgs] to parse the directive arguments.
116+
Visit(ctx context.Context, d DirectiveContext) error
117+
}
118+
119+
// DirectiveVisitors registers one or more directive visitors with the schema.
120+
// Visitor names must be unique within a schema.
121+
func DirectiveVisitors(visitors ...DirectiveVisitor) SchemaOpt {
122+
return func(s *Schema) {
123+
for _, v := range visitors {
124+
visitor, err := newDirectiveVisitor(v)
125+
if err != nil {
126+
s.optErr = err
127+
return
128+
}
129+
s.directiveVisitors = append(s.directiveVisitors, visitor)
130+
}
131+
}
132+
}
133+
134+
func newDirectiveVisitor(visitor DirectiveVisitor) (DirectiveVisitor, error) {
135+
if visitor == nil {
136+
return nil, fmt.Errorf("directive visitor is nil")
137+
}
138+
139+
name := visitor.Name()
140+
if name == "" {
141+
return nil, fmt.Errorf("directive visitor must have a non-empty name")
142+
}
143+
144+
return visitor, nil
145+
}
146+
147+
func (s *Schema) validateDirectiveVisitors() error {
148+
seen := make(map[string]struct{}, len(s.directiveVisitors))
149+
for _, v := range s.directiveVisitors {
150+
name := v.Name()
151+
def := s.schema.Directives[name]
152+
if def == nil {
153+
return fmt.Errorf("directive %q is not defined in the schema", name)
154+
}
155+
if _, ok := seen[name]; ok {
156+
return fmt.Errorf("directive visitor %q is already registered", name)
157+
}
158+
seen[name] = struct{}{}
159+
}
160+
161+
return nil
162+
}
163+
164+
func directiveArgs(schema *ast.Schema, d *ast.Directive, vars map[string]any) (map[string]any, error) {
165+
if d == nil {
166+
return nil, fmt.Errorf("directive is nil")
167+
}
168+
def := schema.Directives[d.Name.Name]
169+
if def == nil {
170+
return nil, fmt.Errorf("directive %q is not defined in the schema", d.Name.Name)
171+
}
172+
args := make(map[string]any, len(def.Arguments))
173+
for _, arg := range def.Arguments {
174+
if v, ok := d.Arguments.Get(arg.Name.Name); ok {
175+
if isNilValue(v) {
176+
args[arg.Name.Name] = nil
177+
continue
178+
}
179+
args[arg.Name.Name] = v.Deserialize(vars)
180+
continue
181+
}
182+
if arg.Default != nil {
183+
args[arg.Name.Name] = arg.Default.Deserialize(nil)
184+
continue
185+
}
186+
args[arg.Name.Name] = nil
187+
}
188+
return args, nil
189+
}
190+
191+
func isNilValue(v ast.Value) bool {
192+
if v == nil {
193+
return true
194+
}
195+
196+
rv := reflect.ValueOf(v)
197+
switch rv.Kind() {
198+
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
199+
return rv.IsNil()
200+
default:
201+
return false
202+
}
203+
}
204+
205+
func (s *Schema) directiveArgsPacker(def *ast.DirectiveDefinition, typ reflect.Type) (*packer.StructPacker, error) {
206+
if def == nil {
207+
return nil, fmt.Errorf("directive definition is nil")
208+
}
209+
key := directiveArgsCacheKey{def: def, typ: typ}
210+
211+
s.directiveArgsMu.RLock()
212+
sp := s.directiveArgsPackers[key]
213+
s.directiveArgsMu.RUnlock()
214+
if sp != nil {
215+
return sp, nil
216+
}
217+
218+
b := packer.NewBuilder()
219+
sp, err := b.MakeStructPacker(def.Arguments, typ)
220+
if err != nil {
221+
return nil, err
222+
}
223+
if err := b.Finish(); err != nil {
224+
return nil, err
225+
}
226+
227+
s.directiveArgsMu.Lock()
228+
defer s.directiveArgsMu.Unlock()
229+
if s.directiveArgsPackers == nil {
230+
s.directiveArgsPackers = make(map[directiveArgsCacheKey]*packer.StructPacker)
231+
}
232+
if cached := s.directiveArgsPackers[key]; cached != nil {
233+
return cached, nil
234+
}
235+
s.directiveArgsPackers[key] = sp
236+
return sp, nil
237+
}
238+
239+
func (s *Schema) buildDirectiveCaches() {
240+
if s.directiveArgsPackers == nil {
241+
s.directiveArgsPackers = make(map[directiveArgsCacheKey]*packer.StructPacker)
242+
}
243+
s.directiveVisitorsByName = make(map[string]DirectiveVisitor, len(s.directiveVisitors))
244+
for _, v := range s.directiveVisitors {
245+
name := v.Name()
246+
s.directiveVisitorsByName[name] = v
247+
}
248+
}
249+
250+
func (s *Schema) runDirectiveVisitors(ctx context.Context, vars map[string]any, sels []selected.Selection) []*errors.QueryError {
251+
if len(s.directiveVisitorsByName) == 0 {
252+
return nil
253+
}
254+
255+
var errs []*errors.QueryError
256+
257+
path := make([]any, 0, 8)
258+
var walk func([]selected.Selection)
259+
walk = func(selections []selected.Selection) {
260+
for _, sel := range selections {
261+
switch sel := sel.(type) {
262+
case *selected.SchemaField:
263+
path = append(path, sel.Alias)
264+
for _, d := range sel.Directives {
265+
hook, ok := s.directiveVisitorsByName[d.Name.Name]
266+
if !ok {
267+
continue
268+
}
269+
args, err := directiveArgs(s.schema, d, vars)
270+
if err != nil {
271+
errs = append(errs, &errors.QueryError{Message: err.Error(), Path: append([]any(nil), path...)})
272+
continue
273+
}
274+
dctx := DirectiveContext{
275+
owner: s,
276+
schema: s.schema,
277+
fieldArgs: sel.Args,
278+
directive: d,
279+
args: args,
280+
}
281+
err = hook.Visit(ctx, dctx)
282+
if err != nil {
283+
errs = append(errs, &errors.QueryError{Message: err.Error(), Path: append([]any(nil), path...)})
284+
}
285+
}
286+
walk(sel.Sels)
287+
path = path[:len(path)-1]
288+
case *selected.TypeAssertion:
289+
walk(sel.Sels)
290+
}
291+
}
292+
}
293+
294+
walk(sels)
295+
if len(errs) != 0 {
296+
return errs
297+
}
298+
return nil
299+
}

0 commit comments

Comments
 (0)