Skip to content

Commit a363262

Browse files
rudleekoSean Sorrellpavelnikolov
authored
Add Custom Directive Support for Fields (#543)
* Added support of custom directives Co-authored-by: Vincent Composieux <[email protected]> Co-authored-by: Sean Sorrell <[email protected]> Co-authored-by: Pavel Nikolov <[email protected]> Co-authored-by: pavelnikolov <[email protected]>
1 parent 5e16071 commit a363262

File tree

11 files changed

+668
-9
lines changed

11 files changed

+668
-9
lines changed

README.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44

55
The goal of this project is to provide full support of the [GraphQL draft specification](https://facebook.github.io/graphql/draft) with a set of idiomatic, easy to use Go packages.
66

7-
While still under heavy development (`internal` APIs are almost certainly subject to change), this library is
8-
safe for production use.
7+
While still under development (`internal` and `directives` APIs are almost certainly subject to change), this library is safe for production use.
98

109
## Features
1110

@@ -17,14 +16,15 @@ safe for production use.
1716
- handles panics in resolvers
1817
- parallel execution of resolvers
1918
- subscriptions
20-
- [sample WS transport](https://github.com/graph-gophers/graphql-transport-ws)
19+
- [sample WS transport](https://github.com/graph-gophers/graphql-transport-ws)
20+
- directive visitors on fields (the API is subject to change in future versions)
2121

2222
## Roadmap
2323

2424
We're trying out the GitHub Project feature to manage `graphql-go`'s [development roadmap](https://github.com/graph-gophers/graphql-go/projects/1).
2525
Feedback is welcome and appreciated.
2626

27-
## (Some) Documentation
27+
## (Some) Documentation [![GoDoc](https://godoc.org/github.com/graph-gophers/graphql-go?status.svg)](https://godoc.org/github.com/graph-gophers/graphql-go)
2828

2929
### Getting started
3030

directives/doc.go

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/*
2+
package directives contains a Visitor Pattern implementation of Schema Directives for Fields.
3+
*/
4+
package directives

directives/visitor.go

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package directives
2+
3+
import (
4+
"context"
5+
6+
"github.com/graph-gophers/graphql-go/types"
7+
)
8+
9+
// Visitor defines the interface that clients should use to implement a Directive
10+
// see the graphql.DirectiveVisitors() Schema Option.
11+
type Visitor interface {
12+
// Before() is always called when the operation includes a directive matching this implementation's name.
13+
// When the first return value is true, the field resolver will not be called.
14+
// Errors in Before() will prevent field resolution.
15+
Before(ctx context.Context, directive *types.Directive, input interface{}) (skipResolver bool, err error)
16+
// After is called if Before() *and* the field resolver do not error.
17+
After(ctx context.Context, directive *types.Directive, output interface{}) (modified interface{}, err error)
18+
}
+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# @hasRole directive
2+
3+
## Overview
4+
A simple example of naive authorization directive which returns an error if the user in the context doesn't have the required role. Make sure that in production applications you use thread-safe maps for roles as an instance of the user struct might be accessed from multiple goroutines. In this naive example we use a simeple map which is not thread-safe. The required role to access a resolver is passed as an argument to the directive, for example, `@hasRole(role: ADMIN)`.
5+
6+
## Getting started
7+
To run this server
8+
9+
`go run ./example/directives/authorization/server/server.go`
10+
11+
Navigate to https://localhost:8080 in your browser to interact with the Graph<i>i</i>QL UI.
12+
13+
## Testing with curl
14+
Access public resolver:
15+
```
16+
$ curl 'http://localhost:8080/query' \
17+
-H 'Accept: application/json' \
18+
--data-raw '{"query":"# mutation {\nquery {\n publicGreet(name: \"John\")\n}","variables":null}'
19+
20+
{"data":{"publicGreet":"Hello from the public resolver, John!"}}
21+
```
22+
Try accessing protected resolver without required role:
23+
```
24+
$ curl 'http://localhost:8080/query' \
25+
-H 'Accept: application/json' \
26+
--data-raw '{"query":"# mutation {\nquery {\n privateGreet(name: \"John\")\n}","variables":null}'
27+
{"errors":[{"message":"access denied, \"admin\" role required","path":["privateGreet"]}],"data":null}
28+
```
29+
Try accessing protected resolver again with appropriate role:
30+
```
31+
$ curl 'http://localhost:8080/query' \
32+
-H 'Accept: application/json' \
33+
-H 'role: admin' \
34+
--data-raw '{"query":"# mutation {\nquery {\n privateGreet(name: \"John\")\n}","variables":null}'
35+
{"data":{"privateGreet":"Hi from the protected resolver, John!"}}
36+
```
37+
38+
## Implementation details
39+
40+
1. Add directive definition to your shema:
41+
```graphql
42+
directive @hasRole(role: Role!) on FIELD_DEFINITION
43+
```
44+
45+
2. Add directive to the protected fields in the schema:
46+
```graphql
47+
type Query {
48+
# other field resolvers here
49+
privateGreet(name: String!): String! @hasRole(role: ADMIN)
50+
}
51+
```
52+
53+
3. Define a user Go type which can have a slice of roles where each role is a string:
54+
```go
55+
type User struct {
56+
ID string
57+
Roles map[string]struct{}
58+
}
59+
60+
func (u *User) AddRole(r string) {
61+
if u.Roles == nil {
62+
u.Roles = map[string]struct{}{}
63+
}
64+
u.Roles[r] = struct{}{}
65+
}
66+
67+
func (u *User) HasRole(r string) bool {
68+
_, ok := u.Roles[r]
69+
return ok
70+
}
71+
```
72+
73+
4. Define a Go type which implements the DirevtiveVisitor interface:
74+
```go
75+
type HasRoleDirective struct{}
76+
77+
func (h *HasRoleDirective) Before(ctx context.Context, directive *types.Directive, input interface{}) (bool, error) {
78+
u, ok := user.FromContext(ctx)
79+
if !ok {
80+
return true, fmt.Errorf("user not provided in cotext")
81+
}
82+
role := strings.ToLower((directive.Arguments.MustGet("role").String())
83+
if !u.HasRole(role) {
84+
return true, fmt.Errorf("access denied, %q role required", role)
85+
}
86+
return false, nil
87+
}
88+
89+
// After is a no-op and returns the output unchanged.
90+
func (h *HasRoleDirective) After(ctx context.Context, directive *types.Directive, output interface{}) (interface{}, error) {
91+
return output, nil
92+
}
93+
```
94+
95+
5. Pay attention to the schmema options. Directive visitors are added as schema option:
96+
```go
97+
opts := []graphql.SchemaOpt{
98+
graphql.DirectiveVisitors(map[string]directives.Visitor{
99+
"hasRole": &authorization.HasRoleDirective{},
100+
}),
101+
// other options go here
102+
}
103+
schema := graphql.MustParseSchema(authorization.Schema, &authorization.Resolver{}, opts...)
104+
```
105+
106+
6. Add a middleware to the HTTP handler which would read the `role` HTTP header and add that role to the slice of user roles. This naive middleware assumes that there is authentication proxy (e.g. Nginx, Envoy, Contour etc.) in front of this server which would authenticate the user and add their role in a header. In production application it would be fine if the same application handles the authentication and adds the user to the context. This is the middleware in this example:
107+
```go
108+
func auth(next http.Handler) http.Handler {
109+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110+
u := &user.User{}
111+
role := r.Header.Get("role")
112+
if role != "" {
113+
u.AddRole(role)
114+
}
115+
ctx := user.AddToContext(context.Background(), u)
116+
next.ServeHTTP(w, r.WithContext(ctx))
117+
})
118+
}
119+
```
120+
121+
7. Wrap the GraphQL handler with the auth middleware:
122+
```go
123+
http.Handle("/query", auth(&relay.Handler{Schema: schema}))
124+
```
125+
126+
8. In order to access the private resolver add a role header like below:
127+
128+
![accessing a private resolver using role header](graphiql-has-role-example.png)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Package authorization contains a simple GraphQL schema using directives.
2+
package authorization
3+
4+
import (
5+
"context"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/graph-gophers/graphql-go/example/directives/authorization/user"
10+
"github.com/graph-gophers/graphql-go/types"
11+
)
12+
13+
const Schema = `
14+
schema {
15+
query: Query
16+
}
17+
18+
directive @hasRole(role: Role!) on FIELD_DEFINITION
19+
20+
type Query {
21+
publicGreet(name: String!): String!
22+
privateGreet(name: String!): String! @hasRole(role: ADMIN)
23+
}
24+
25+
enum Role {
26+
ADMIN
27+
USER
28+
}
29+
`
30+
31+
type HasRoleDirective struct{}
32+
33+
func (h *HasRoleDirective) Before(ctx context.Context, directive *types.Directive, input interface{}) (bool, error) {
34+
u, ok := user.FromContext(ctx)
35+
if !ok {
36+
return true, fmt.Errorf("user not provided in cotext")
37+
}
38+
role := strings.ToLower(directive.Arguments.MustGet("role").String())
39+
if !u.HasRole(role) {
40+
return true, fmt.Errorf("access denied, %q role required", role)
41+
}
42+
return false, nil
43+
}
44+
45+
func (h *HasRoleDirective) After(ctx context.Context, directive *types.Directive, output interface{}) (interface{}, error) {
46+
return output, nil
47+
}
48+
49+
type Resolver struct{}
50+
51+
func (r *Resolver) PublicGreet(ctx context.Context, args struct{ Name string }) string {
52+
return fmt.Sprintf("Hello from the public resolver, %s!", args.Name)
53+
}
54+
55+
func (r *Resolver) PrivateGreet(ctx context.Context, args struct{ Name string }) string {
56+
return fmt.Sprintf("Hi from the protected resolver, %s!", args.Name)
57+
}
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"log"
6+
"net/http"
7+
8+
"github.com/graph-gophers/graphql-go"
9+
"github.com/graph-gophers/graphql-go/directives"
10+
"github.com/graph-gophers/graphql-go/example/directives/authorization"
11+
"github.com/graph-gophers/graphql-go/example/directives/authorization/user"
12+
"github.com/graph-gophers/graphql-go/relay"
13+
)
14+
15+
func main() {
16+
opts := []graphql.SchemaOpt{
17+
graphql.DirectiveVisitors(map[string]directives.Visitor{
18+
"hasRole": &authorization.HasRoleDirective{},
19+
}),
20+
// other options go here
21+
}
22+
schema := graphql.MustParseSchema(authorization.Schema, &authorization.Resolver{}, opts...)
23+
24+
http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+
w.Write(page)
26+
}))
27+
28+
http.Handle("/query", auth(&relay.Handler{Schema: schema}))
29+
30+
log.Fatal(http.ListenAndServe(":8080", nil))
31+
}
32+
33+
func auth(next http.Handler) http.Handler {
34+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35+
u := &user.User{}
36+
role := r.Header.Get("role")
37+
if role != "" {
38+
u.AddRole(role)
39+
}
40+
ctx := user.AddToContext(context.Background(), u)
41+
next.ServeHTTP(w, r.WithContext(ctx))
42+
})
43+
}
44+
45+
var page = []byte(`
46+
<!DOCTYPE html>
47+
<html lang="en">
48+
<head>
49+
<title>GraphiQL</title>
50+
<style>
51+
body {
52+
height: 100%;
53+
margin: 0;
54+
width: 100%;
55+
overflow: hidden;
56+
}
57+
#graphiql {
58+
height: 100vh;
59+
}
60+
</style>
61+
<script src="https://unpkg.com/react@17/umd/react.development.js" integrity="sha512-Vf2xGDzpqUOEIKO+X2rgTLWPY+65++WPwCHkX2nFMu9IcstumPsf/uKKRd5prX3wOu8Q0GBylRpsDB26R6ExOg==" crossorigin="anonymous"></script>
62+
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" integrity="sha512-Wr9OKCTtq1anK0hq5bY3X/AvDI5EflDSAh0mE9gma+4hl+kXdTJPKZ3TwLMBcrgUeoY0s3dq9JjhCQc7vddtFg==" crossorigin="anonymous"></script>
63+
<link rel="stylesheet" href="https://unpkg.com/graphiql/graphiql.min.css" />
64+
</head>
65+
<body>
66+
<div id="graphiql">Loading...</div>
67+
<script src="https://unpkg.com/graphiql/graphiql.min.js" type="application/javascript"></script>
68+
<script>
69+
ReactDOM.render(
70+
React.createElement(GraphiQL, {
71+
fetcher: GraphiQL.createFetcher({url: '/query'}),
72+
defaultEditorToolsVisibility: true,
73+
}),
74+
document.getElementById('graphiql'),
75+
);
76+
</script>
77+
</body>
78+
</html>
79+
`)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// package user contains a naive implementation of an user with roles.
2+
// Each user can be assigned roles and added to/retrieved from context.
3+
package user
4+
5+
import (
6+
"context"
7+
)
8+
9+
type userKey string
10+
11+
const contextKey userKey = "user"
12+
13+
type User struct {
14+
ID string
15+
Roles map[string]struct{}
16+
}
17+
18+
func (u *User) AddRole(r string) {
19+
if u.Roles == nil {
20+
u.Roles = map[string]struct{}{}
21+
}
22+
u.Roles[r] = struct{}{}
23+
}
24+
25+
func (u *User) HasRole(r string) bool {
26+
_, ok := u.Roles[r]
27+
return ok
28+
}
29+
30+
func AddToContext(ctx context.Context, u *User) context.Context {
31+
return context.WithValue(ctx, contextKey, u)
32+
}
33+
34+
func FromContext(ctx context.Context) (*User, bool) {
35+
u, ok := ctx.Value(contextKey).(*User)
36+
return u, ok
37+
}

graphql.go

+11
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"time"
88

9+
"github.com/graph-gophers/graphql-go/directives"
910
"github.com/graph-gophers/graphql-go/errors"
1011
"github.com/graph-gophers/graphql-go/internal/common"
1112
"github.com/graph-gophers/graphql-go/internal/exec"
@@ -84,6 +85,7 @@ type Schema struct {
8485
useStringDescriptions bool
8586
disableIntrospection bool
8687
subscribeResolverTimeout time.Duration
88+
visitors map[string]directives.Visitor
8789
}
8890

8991
func (s *Schema) ASTSchema() *types.Schema {
@@ -177,6 +179,14 @@ func SubscribeResolverTimeout(timeout time.Duration) SchemaOpt {
177179
}
178180
}
179181

182+
// DirectiveVisitors defines the implementation for each directive.
183+
// Per the GraphQL specification, each Field Directive in the schema must have an implementation here.
184+
func DirectiveVisitors(visitors map[string]directives.Visitor) SchemaOpt {
185+
return func(s *Schema) {
186+
s.visitors = visitors
187+
}
188+
}
189+
180190
// Response represents a typical response of a GraphQL server. It may be encoded to JSON directly or
181191
// it may be further processed to a custom response type, for example to include custom error data.
182192
// Errors are intentionally serialized first based on the advice in https://github.com/facebook/graphql/commit/7b40390d48680b15cb93e02d46ac5eb249689876#diff-757cea6edf0288677a9eea4cfc801d87R107
@@ -269,6 +279,7 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str
269279
Tracer: s.tracer,
270280
Logger: s.logger,
271281
PanicHandler: s.panicHandler,
282+
Visitors: s.visitors,
272283
}
273284
varTypes := make(map[string]*introspection.Type)
274285
for _, v := range op.Vars {

0 commit comments

Comments
 (0)