forked from graph-gophers/graphql-go
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Custom Directive Support for Fields (graph-gophers#543)
* Added support of custom directives Co-authored-by: Vincent Composieux <vincent.composieux@gmail.com> Co-authored-by: Sean Sorrell <me@seansorrell.org> Co-authored-by: Pavel Nikolov <pavelnikolov@users.noreply.github.com> Co-authored-by: pavelnikolov <me@pavelnikolov.net>
- Loading branch information
Showing
11 changed files
with
668 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
/* | ||
package directives contains a Visitor Pattern implementation of Schema Directives for Fields. | ||
*/ | ||
package directives |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
package directives | ||
|
||
import ( | ||
"context" | ||
|
||
"github.com/graph-gophers/graphql-go/types" | ||
) | ||
|
||
// Visitor defines the interface that clients should use to implement a Directive | ||
// see the graphql.DirectiveVisitors() Schema Option. | ||
type Visitor interface { | ||
// Before() is always called when the operation includes a directive matching this implementation's name. | ||
// When the first return value is true, the field resolver will not be called. | ||
// Errors in Before() will prevent field resolution. | ||
Before(ctx context.Context, directive *types.Directive, input interface{}) (skipResolver bool, err error) | ||
// After is called if Before() *and* the field resolver do not error. | ||
After(ctx context.Context, directive *types.Directive, output interface{}) (modified interface{}, err error) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
# @hasRole directive | ||
|
||
## Overview | ||
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)`. | ||
|
||
## Getting started | ||
To run this server | ||
|
||
`go run ./example/directives/authorization/server/server.go` | ||
|
||
Navigate to https://localhost:8080 in your browser to interact with the Graph<i>i</i>QL UI. | ||
|
||
## Testing with curl | ||
Access public resolver: | ||
``` | ||
$ curl 'http://localhost:8080/query' \ | ||
-H 'Accept: application/json' \ | ||
--data-raw '{"query":"# mutation {\nquery {\n publicGreet(name: \"John\")\n}","variables":null}' | ||
{"data":{"publicGreet":"Hello from the public resolver, John!"}} | ||
``` | ||
Try accessing protected resolver without required role: | ||
``` | ||
$ curl 'http://localhost:8080/query' \ | ||
-H 'Accept: application/json' \ | ||
--data-raw '{"query":"# mutation {\nquery {\n privateGreet(name: \"John\")\n}","variables":null}' | ||
{"errors":[{"message":"access denied, \"admin\" role required","path":["privateGreet"]}],"data":null} | ||
``` | ||
Try accessing protected resolver again with appropriate role: | ||
``` | ||
$ curl 'http://localhost:8080/query' \ | ||
-H 'Accept: application/json' \ | ||
-H 'role: admin' \ | ||
--data-raw '{"query":"# mutation {\nquery {\n privateGreet(name: \"John\")\n}","variables":null}' | ||
{"data":{"privateGreet":"Hi from the protected resolver, John!"}} | ||
``` | ||
|
||
## Implementation details | ||
|
||
1. Add directive definition to your shema: | ||
```graphql | ||
directive @hasRole(role: Role!) on FIELD_DEFINITION | ||
``` | ||
|
||
2. Add directive to the protected fields in the schema: | ||
```graphql | ||
type Query { | ||
# other field resolvers here | ||
privateGreet(name: String!): String! @hasRole(role: ADMIN) | ||
} | ||
``` | ||
|
||
3. Define a user Go type which can have a slice of roles where each role is a string: | ||
```go | ||
type User struct { | ||
ID string | ||
Roles map[string]struct{} | ||
} | ||
|
||
func (u *User) AddRole(r string) { | ||
if u.Roles == nil { | ||
u.Roles = map[string]struct{}{} | ||
} | ||
u.Roles[r] = struct{}{} | ||
} | ||
|
||
func (u *User) HasRole(r string) bool { | ||
_, ok := u.Roles[r] | ||
return ok | ||
} | ||
``` | ||
|
||
4. Define a Go type which implements the DirevtiveVisitor interface: | ||
```go | ||
type HasRoleDirective struct{} | ||
|
||
func (h *HasRoleDirective) Before(ctx context.Context, directive *types.Directive, input interface{}) (bool, error) { | ||
u, ok := user.FromContext(ctx) | ||
if !ok { | ||
return true, fmt.Errorf("user not provided in cotext") | ||
} | ||
role := strings.ToLower((directive.Arguments.MustGet("role").String()) | ||
if !u.HasRole(role) { | ||
return true, fmt.Errorf("access denied, %q role required", role) | ||
} | ||
return false, nil | ||
} | ||
|
||
// After is a no-op and returns the output unchanged. | ||
func (h *HasRoleDirective) After(ctx context.Context, directive *types.Directive, output interface{}) (interface{}, error) { | ||
return output, nil | ||
} | ||
``` | ||
|
||
5. Pay attention to the schmema options. Directive visitors are added as schema option: | ||
```go | ||
opts := []graphql.SchemaOpt{ | ||
graphql.DirectiveVisitors(map[string]directives.Visitor{ | ||
"hasRole": &authorization.HasRoleDirective{}, | ||
}), | ||
// other options go here | ||
} | ||
schema := graphql.MustParseSchema(authorization.Schema, &authorization.Resolver{}, opts...) | ||
``` | ||
|
||
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: | ||
```go | ||
func auth(next http.Handler) http.Handler { | ||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
u := &user.User{} | ||
role := r.Header.Get("role") | ||
if role != "" { | ||
u.AddRole(role) | ||
} | ||
ctx := user.AddToContext(context.Background(), u) | ||
next.ServeHTTP(w, r.WithContext(ctx)) | ||
}) | ||
} | ||
``` | ||
|
||
7. Wrap the GraphQL handler with the auth middleware: | ||
```go | ||
http.Handle("/query", auth(&relay.Handler{Schema: schema})) | ||
``` | ||
|
||
8. In order to access the private resolver add a role header like below: | ||
|
||
![accessing a private resolver using role header](graphiql-has-role-example.png) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
// Package authorization contains a simple GraphQL schema using directives. | ||
package authorization | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/graph-gophers/graphql-go/example/directives/authorization/user" | ||
"github.com/graph-gophers/graphql-go/types" | ||
) | ||
|
||
const Schema = ` | ||
schema { | ||
query: Query | ||
} | ||
directive @hasRole(role: Role!) on FIELD_DEFINITION | ||
type Query { | ||
publicGreet(name: String!): String! | ||
privateGreet(name: String!): String! @hasRole(role: ADMIN) | ||
} | ||
enum Role { | ||
ADMIN | ||
USER | ||
} | ||
` | ||
|
||
type HasRoleDirective struct{} | ||
|
||
func (h *HasRoleDirective) Before(ctx context.Context, directive *types.Directive, input interface{}) (bool, error) { | ||
u, ok := user.FromContext(ctx) | ||
if !ok { | ||
return true, fmt.Errorf("user not provided in cotext") | ||
} | ||
role := strings.ToLower(directive.Arguments.MustGet("role").String()) | ||
if !u.HasRole(role) { | ||
return true, fmt.Errorf("access denied, %q role required", role) | ||
} | ||
return false, nil | ||
} | ||
|
||
func (h *HasRoleDirective) After(ctx context.Context, directive *types.Directive, output interface{}) (interface{}, error) { | ||
return output, nil | ||
} | ||
|
||
type Resolver struct{} | ||
|
||
func (r *Resolver) PublicGreet(ctx context.Context, args struct{ Name string }) string { | ||
return fmt.Sprintf("Hello from the public resolver, %s!", args.Name) | ||
} | ||
|
||
func (r *Resolver) PrivateGreet(ctx context.Context, args struct{ Name string }) string { | ||
return fmt.Sprintf("Hi from the protected resolver, %s!", args.Name) | ||
} |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"log" | ||
"net/http" | ||
|
||
"github.com/graph-gophers/graphql-go" | ||
"github.com/graph-gophers/graphql-go/directives" | ||
"github.com/graph-gophers/graphql-go/example/directives/authorization" | ||
"github.com/graph-gophers/graphql-go/example/directives/authorization/user" | ||
"github.com/graph-gophers/graphql-go/relay" | ||
) | ||
|
||
func main() { | ||
opts := []graphql.SchemaOpt{ | ||
graphql.DirectiveVisitors(map[string]directives.Visitor{ | ||
"hasRole": &authorization.HasRoleDirective{}, | ||
}), | ||
// other options go here | ||
} | ||
schema := graphql.MustParseSchema(authorization.Schema, &authorization.Resolver{}, opts...) | ||
|
||
http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
w.Write(page) | ||
})) | ||
|
||
http.Handle("/query", auth(&relay.Handler{Schema: schema})) | ||
|
||
log.Fatal(http.ListenAndServe(":8080", nil)) | ||
} | ||
|
||
func auth(next http.Handler) http.Handler { | ||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
u := &user.User{} | ||
role := r.Header.Get("role") | ||
if role != "" { | ||
u.AddRole(role) | ||
} | ||
ctx := user.AddToContext(context.Background(), u) | ||
next.ServeHTTP(w, r.WithContext(ctx)) | ||
}) | ||
} | ||
|
||
var page = []byte(` | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
<head> | ||
<title>GraphiQL</title> | ||
<style> | ||
body { | ||
height: 100%; | ||
margin: 0; | ||
width: 100%; | ||
overflow: hidden; | ||
} | ||
#graphiql { | ||
height: 100vh; | ||
} | ||
</style> | ||
<script src="https://unpkg.com/react@17/umd/react.development.js" integrity="sha512-Vf2xGDzpqUOEIKO+X2rgTLWPY+65++WPwCHkX2nFMu9IcstumPsf/uKKRd5prX3wOu8Q0GBylRpsDB26R6ExOg==" crossorigin="anonymous"></script> | ||
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" integrity="sha512-Wr9OKCTtq1anK0hq5bY3X/AvDI5EflDSAh0mE9gma+4hl+kXdTJPKZ3TwLMBcrgUeoY0s3dq9JjhCQc7vddtFg==" crossorigin="anonymous"></script> | ||
<link rel="stylesheet" href="https://unpkg.com/graphiql/graphiql.min.css" /> | ||
</head> | ||
<body> | ||
<div id="graphiql">Loading...</div> | ||
<script src="https://unpkg.com/graphiql/graphiql.min.js" type="application/javascript"></script> | ||
<script> | ||
ReactDOM.render( | ||
React.createElement(GraphiQL, { | ||
fetcher: GraphiQL.createFetcher({url: '/query'}), | ||
defaultEditorToolsVisibility: true, | ||
}), | ||
document.getElementById('graphiql'), | ||
); | ||
</script> | ||
</body> | ||
</html> | ||
`) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
// package user contains a naive implementation of an user with roles. | ||
// Each user can be assigned roles and added to/retrieved from context. | ||
package user | ||
|
||
import ( | ||
"context" | ||
) | ||
|
||
type userKey string | ||
|
||
const contextKey userKey = "user" | ||
|
||
type User struct { | ||
ID string | ||
Roles map[string]struct{} | ||
} | ||
|
||
func (u *User) AddRole(r string) { | ||
if u.Roles == nil { | ||
u.Roles = map[string]struct{}{} | ||
} | ||
u.Roles[r] = struct{}{} | ||
} | ||
|
||
func (u *User) HasRole(r string) bool { | ||
_, ok := u.Roles[r] | ||
return ok | ||
} | ||
|
||
func AddToContext(ctx context.Context, u *User) context.Context { | ||
return context.WithValue(ctx, contextKey, u) | ||
} | ||
|
||
func FromContext(ctx context.Context) (*User, bool) { | ||
u, ok := ctx.Value(contextKey).(*User) | ||
return u, ok | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.