Skip to content

Commit

Permalink
Add Custom Directive Support for Fields (graph-gophers#543)
Browse files Browse the repository at this point in the history
* 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
5 people authored and KNiepok committed Feb 28, 2023
1 parent 2a38a95 commit d7e67dd
Show file tree
Hide file tree
Showing 11 changed files with 668 additions and 9 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@

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.

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

## Features

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

## Roadmap

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

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

### Getting started

Expand Down
4 changes: 4 additions & 0 deletions directives/doc.go
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
18 changes: 18 additions & 0 deletions directives/visitor.go
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)
}
128 changes: 128 additions & 0 deletions example/directives/authorization/README.md
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)
57 changes: 57 additions & 0 deletions example/directives/authorization/authorization.go
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.
79 changes: 79 additions & 0 deletions example/directives/authorization/server/server.go
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>
`)
37 changes: 37 additions & 0 deletions example/directives/authorization/user/user.go
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
}
11 changes: 11 additions & 0 deletions graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"time"

"github.com/graph-gophers/graphql-go/directives"
"github.com/graph-gophers/graphql-go/errors"
"github.com/graph-gophers/graphql-go/internal/common"
"github.com/graph-gophers/graphql-go/internal/exec"
Expand Down Expand Up @@ -90,6 +91,7 @@ type Schema struct {
useStringDescriptions bool
disableIntrospection bool
subscribeResolverTimeout time.Duration
visitors map[string]directives.Visitor
middlewares []Middleware
}

Expand Down Expand Up @@ -201,6 +203,14 @@ func SubscribeResolverTimeout(timeout time.Duration) SchemaOpt {
}
}

// DirectiveVisitors defines the implementation for each directive.
// Per the GraphQL specification, each Field Directive in the schema must have an implementation here.
func DirectiveVisitors(visitors map[string]directives.Visitor) SchemaOpt {
return func(s *Schema) {
s.visitors = visitors
}
}

// 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
Expand Down Expand Up @@ -299,6 +309,7 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str
Tracer: s.tracer,
Logger: s.logger,
PanicHandler: s.panicHandler,
Visitors: s.visitors,
}
varTypes := make(map[string]*introspection.Type)
for _, v := range op.Vars {
Expand Down
Loading

0 comments on commit d7e67dd

Please sign in to comment.